@octanejs/vite-plugin 0.1.61 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@octanejs/vite-plugin",
3
- "version": "0.1.61",
3
+ "version": "0.2.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "engines": {
@@ -47,11 +47,11 @@
47
47
  },
48
48
  "dependencies": {
49
49
  "@ripple-ts/adapter": "^0.4.0",
50
- "@octanejs/app-core": "0.0.57"
50
+ "@octanejs/app-core": "0.1.0"
51
51
  },
52
52
  "peerDependencies": {
53
53
  "vite": "^8.0.16",
54
- "octane": "^0.6.0"
54
+ "octane": "^0.8.0"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@types/jsdom": "30.0.0",
@@ -59,6 +59,6 @@
59
59
  "playwright": "^1.61.1",
60
60
  "playwright-webkit": "1.63.0",
61
61
  "vite": "^8.1.5",
62
- "octane": "0.6.0"
62
+ "octane": "0.8.0"
63
63
  }
64
64
  }
package/src/index.js CHANGED
@@ -9,7 +9,10 @@ import { AsyncLocalStorage } from 'node:async_hooks';
9
9
  import { createRequire } from 'node:module';
10
10
 
11
11
  import { octane as octaneCompiler } from 'octane/compiler/vite';
12
- import { INDEPENDENT_HYDRATION_MANIFEST_FILENAME } from 'octane/compiler/bundler';
12
+ import {
13
+ INDEPENDENT_HYDRATION_MANIFEST_FILENAME,
14
+ analyzeIslandsShell,
15
+ } from 'octane/compiler/bundler';
13
16
  import {
14
17
  createServerCallHost,
15
18
  handleRpcRequest as handleServerRpcRequest,
@@ -37,6 +40,7 @@ import {
37
40
  SERVER_ONLY_ADAPTER_IDS,
38
41
  create_adapter_browser_stub_source,
39
42
  create_client_entry_source,
43
+ create_islands_entry_source,
40
44
  to_vite_root_import,
41
45
  write_project_generated_file,
42
46
  } from './project-codegen.js';
@@ -45,7 +49,7 @@ import { createClientBuildState } from './client-build.js';
45
49
 
46
50
  import { patch_global_fetch, is_rpc_request } from '@ripple-ts/adapter/rpc';
47
51
 
48
- import { get_route_entry_path } from './routes.js';
52
+ import { get_route_entry_export_name, get_route_entry_path } from './routes.js';
49
53
 
50
54
  // Re-export route classes + config helpers (public API surface).
51
55
  export { RenderRoute, ServerRoute } from './routes.js';
@@ -75,6 +79,9 @@ export {
75
79
 
76
80
  const VIRTUAL_HYDRATE_ID = 'virtual:octane-hydrate';
77
81
  const RESOLVED_VIRTUAL_HYDRATE_ID = '\0virtual:octane-hydrate';
82
+ // `hydrate: 'islands'` routes boot from this renderer-free entry instead.
83
+ const VIRTUAL_ISLANDS_ID = 'virtual:octane-islands';
84
+ const RESOLVED_VIRTUAL_ISLANDS_ID = '\0virtual:octane-islands';
78
85
  const requireFromPlugin = createRequire(import.meta.url);
79
86
  // Mirrors octane/compiler/vite's full-compiler surface. Keeping this list in
80
87
  // sync is especially important for production `module server` discovery.
@@ -215,6 +222,153 @@ function has_route_config(config) {
215
222
  return (config?.router.routes.length ?? 0) > 0;
216
223
  }
217
224
 
225
+ /**
226
+ * @param {ResolvedOctaneConfig | null} config
227
+ * @returns {boolean}
228
+ */
229
+ function has_islands_route(config) {
230
+ return (
231
+ config?.router.routes.some((route) => route.type === 'render' && route.hydrate === 'islands') ??
232
+ false
233
+ );
234
+ }
235
+
236
+ /**
237
+ * Check every islands-only route's shell for client work. The shell's modules
238
+ * never load in the browser, so every component it renders outside its
239
+ * independent islands must be static server output. A build fails on the
240
+ * first problem; dev reports each one and keeps serving.
241
+ *
242
+ * @param {(source: string, importer: string) => Promise<{ id: string, external?: boolean | 'absolute' | 'relative' } | null>} resolve
243
+ * @param {string} projectRoot
244
+ * @param {ResolvedOctaneConfig} config
245
+ * @param {(message: string) => void} report
246
+ * @param {(file: string, exports: string[] | null, values: boolean) => ReturnType<typeof analyzeIslandsShell>} [analyze]
247
+ */
248
+ async function checkIslandsShells(
249
+ resolve,
250
+ projectRoot,
251
+ config,
252
+ report,
253
+ analyze = (file, exports, values) =>
254
+ analyzeIslandsShell(fs.readFileSync(file, 'utf-8'), file, exports, { values }),
255
+ ) {
256
+ // Checked exports per module: a rendered export by name, a value passed into
257
+ // JSX as `?name`, which a rendered check of the same export also covers.
258
+ /** @type {Map<string, Set<string> | null>} */
259
+ const checked = new Map();
260
+ for (const route of config.router.routes) {
261
+ if (route.type !== 'render' || route.hydrate !== 'islands') continue;
262
+ const fail = (/** @type {string} */ message) =>
263
+ report(
264
+ `[@octanejs/vite-plugin] RenderRoute ${JSON.stringify(route.path)} uses hydrate: 'islands', but ${message}`,
265
+ );
266
+ if (config.rootBoundary.pending || config.rootBoundary.catch)
267
+ fail('the configured root boundaries need a hydrated root.');
268
+ const exportName = get_route_entry_export_name(route.entry);
269
+ /** @type {Array<[string, string[] | null, boolean]>} */
270
+ const pending = [];
271
+ for (const [
272
+ modulePath,
273
+ exports,
274
+ ] of /** @type {Array<[string | undefined, string[] | null]>} */ ([
275
+ [get_route_entry_path(route.entry), exportName ? [exportName] : null],
276
+ [route.layout, null],
277
+ ])) {
278
+ if (modulePath)
279
+ pending.push([
280
+ path.resolve(projectRoot, modulePath.startsWith('/') ? `.${modulePath}` : modulePath),
281
+ exports,
282
+ false,
283
+ ]);
284
+ }
285
+ while (pending.length > 0) {
286
+ const [file, exports, values] = /** @type {[string, string[] | null, boolean]} */ (
287
+ pending.shift()
288
+ );
289
+ const keys = exports?.map((name) => (values ? `?${name}` : name));
290
+ const seen = checked.get(file);
291
+ if (
292
+ seen === null ||
293
+ (keys !== undefined &&
294
+ keys.every((key) => seen?.has(key) || (key[0] === '?' && seen?.has(key.slice(1)))))
295
+ )
296
+ continue;
297
+ checked.set(file, keys === undefined ? null : new Set([...(seen ?? []), ...keys]));
298
+ if (!is_octane_module_path(file)) {
299
+ // A plain module has no JSX; a value it exports (a string, an asset
300
+ // URL) renders nothing interactive.
301
+ if (!values) fail(`its shell renders ${file}, which Octane cannot check.`);
302
+ continue;
303
+ }
304
+ const result = analyze(file, exports, values);
305
+ if (result.problems.length > 0) {
306
+ const [problem] = result.problems;
307
+ fail(
308
+ `its shell needs client work at ${path.relative(projectRoot, file)}:${problem.line}:${problem.column}: ${problem.message}.`,
309
+ );
310
+ }
311
+ for (const component of result.components) {
312
+ const resolved = await resolve(component.source, file);
313
+ if (!resolved || resolved.external) {
314
+ if (!component.value)
315
+ fail(`its shell renders ${component.source}, which Octane cannot check.`);
316
+ } else
317
+ pending.push([
318
+ resolved.id.split('?')[0],
319
+ [component.exportName],
320
+ component.value === true,
321
+ ]);
322
+ }
323
+ }
324
+ }
325
+ }
326
+
327
+ /**
328
+ * Dev serves an islands-only route whatever its shell does, so an interactive
329
+ * shell would be silently inert there. Warn once per problem, from the same
330
+ * check the build enforces, re-analyzing a module only when it changes.
331
+ *
332
+ * @param {ViteDevServer} server
333
+ * @param {string} projectRoot
334
+ */
335
+ function createIslandsShellWarnings(server, projectRoot) {
336
+ /** @type {Map<string, { mtimeMs: number, result: ReturnType<typeof analyzeIslandsShell> }>} */
337
+ const analyses = new Map();
338
+ /** @type {Set<string>} */
339
+ let warned = new Set();
340
+ /** @param {ResolvedOctaneConfig} config */
341
+ return async (config) => {
342
+ /** @type {Set<string>} */
343
+ const current = new Set();
344
+ await checkIslandsShells(
345
+ async (source, importer) =>
346
+ (await server.environments.ssr.pluginContainer.resolveId(source, importer)) ?? null,
347
+ projectRoot,
348
+ config,
349
+ (message) => current.add(message),
350
+ (file, exports, values) => {
351
+ const key = `${file}\0${exports?.join(',') ?? '*'}\0${values}`;
352
+ const mtimeMs = fs.statSync(file).mtimeMs;
353
+ const cached = analyses.get(key);
354
+ if (cached?.mtimeMs === mtimeMs) return cached.result;
355
+ const result = analyzeIslandsShell(fs.readFileSync(file, 'utf-8'), file, exports, {
356
+ values,
357
+ });
358
+ analyses.set(key, { mtimeMs, result });
359
+ return result;
360
+ },
361
+ );
362
+ for (const message of current)
363
+ if (!warned.has(message))
364
+ server.config.logger.warn(
365
+ `${message} A production build fails until the shell is static.`,
366
+ { timestamp: true },
367
+ );
368
+ warned = current;
369
+ };
370
+ }
371
+
218
372
  /**
219
373
  * Every module path the server can name in #__octane_data — page entries,
220
374
  * layouts, the preHydrate hook, and root boundaries. The generated hydrate
@@ -297,6 +451,8 @@ export function octane(inlineOptions = {}) {
297
451
  let staticEntries = [];
298
452
  /** @type {Record<string, string>} Static module path → emitted client chunk file */
299
453
  let staticEntryFiles = Object.create(null);
454
+ /** @type {string | null} Emitted renderer-free entry for islands-only routes */
455
+ let islandsEntryFile = null;
300
456
  /** @type {Set<string>} Vite-root paths of modules containing `module server` */
301
457
  const serverModuleModules = new Set();
302
458
  /** @type {ViteDevServer | undefined} */
@@ -304,8 +460,10 @@ export function octane(inlineOptions = {}) {
304
460
  const clientBuild = createClientBuildState((buildId) => {
305
461
  if (!devServer) return;
306
462
  const clientGraph = devServer.environments.client.moduleGraph;
307
- const entry = clientGraph.getModuleById(RESOLVED_VIRTUAL_HYDRATE_ID);
308
- if (entry) clientGraph.invalidateModule(entry);
463
+ for (const id of [RESOLVED_VIRTUAL_HYDRATE_ID, RESOLVED_VIRTUAL_ISLANDS_ID]) {
464
+ const entry = clientGraph.getModuleById(id);
465
+ if (entry) clientGraph.invalidateModule(entry);
466
+ }
309
467
  devServer.ws.send('octane:independent-hydration', { buildId, enabled: false });
310
468
  devServer.ws.send({ type: 'full-reload' });
311
469
  });
@@ -458,7 +616,7 @@ export function octane(inlineOptions = {}) {
458
616
  * generated hydrate entry maps them as STATIC dynamic imports Rollup can
459
617
  * chunk and hash.
460
618
  */
461
- buildStart() {
619
+ async buildStart() {
462
620
  if (!isBuild || isSSRBuild || !has_route_config(buildOctaneConfig)) return;
463
621
  clientBuildWritten = false;
464
622
  clientBuild.begin();
@@ -473,6 +631,21 @@ export function octane(inlineOptions = {}) {
473
631
  name: 'octane-hydrate',
474
632
  preserveSignature: 'strict',
475
633
  });
634
+ islandsEntryFile = null;
635
+ if (has_islands_route(buildOctaneConfig)) {
636
+ await checkIslandsShells(
637
+ (source, importer) => this.resolve(source, importer, { skipSelf: true }),
638
+ root,
639
+ /** @type {ResolvedOctaneConfig} */ (buildOctaneConfig),
640
+ (message) => this.error(message),
641
+ );
642
+ this.emitFile({
643
+ type: 'chunk',
644
+ id: VIRTUAL_ISLANDS_ID,
645
+ name: 'octane-islands',
646
+ preserveSignature: 'strict',
647
+ });
648
+ }
476
649
  },
477
650
 
478
651
  writeBundle() {
@@ -483,8 +656,60 @@ export function octane(inlineOptions = {}) {
483
656
  * Preserve the source-to-file relation that Vite's manifest cannot express
484
657
  * when Rolldown promotes a dynamic route entry into a shared chunk.
485
658
  */
486
- generateBundle(_options, bundle) {
659
+ async generateBundle(_options, bundle) {
487
660
  if (!isBuild || isSSRBuild || !has_route_config(buildOctaneConfig)) return;
661
+ if (has_islands_route(buildOctaneConfig)) {
662
+ const islandsEntry = Object.values(bundle).find(
663
+ (output) =>
664
+ output.type === 'chunk' && output.facadeModuleId === RESOLVED_VIRTUAL_ISLANDS_ID,
665
+ );
666
+ if (!islandsEntry || islandsEntry.type !== 'chunk') {
667
+ this.error('The Octane islands entry must retain its own emitted facade.');
668
+ }
669
+ islandsEntryFile = islandsEntry.fileName;
670
+ // Fail closed: an islands-only page loads this entry, the hydration
671
+ // modules it imports lazily, and its preHydrate hook. None may reach the
672
+ // renderer; an island that needs it loads it through its own chunk.
673
+ const renderer = new Set();
674
+ for (const request of ['octane', 'octane/internal/client']) {
675
+ const resolved = await this.resolve(request, undefined, { skipSelf: true });
676
+ if (resolved) renderer.add(resolved.id);
677
+ }
678
+ const preHydrate = /** @type {ResolvedOctaneConfig} */ (buildOctaneConfig).router
679
+ .preHydrate;
680
+ const roots = [RESOLVED_VIRTUAL_ISLANDS_ID];
681
+ if (preHydrate) {
682
+ const resolved = await this.resolve(preHydrate, undefined, { skipSelf: true });
683
+ if (resolved) roots.push(resolved.id);
684
+ }
685
+ const seen = new Set();
686
+ /** @type {string[]} */
687
+ const chain = [];
688
+ /** @param {string} id @returns {boolean} */
689
+ const reaches = (id) => {
690
+ if (seen.has(id)) return false;
691
+ seen.add(id);
692
+ chain.push(id);
693
+ if (renderer.has(id)) return true;
694
+ const info = this.getModuleInfo(id);
695
+ for (const imported of [
696
+ ...(info?.importedIds ?? []),
697
+ ...(info?.dynamicallyImportedIds ?? []),
698
+ ])
699
+ if (reaches(imported)) return true;
700
+ chain.pop();
701
+ return false;
702
+ };
703
+ for (const id of roots) {
704
+ if (reaches(id)) {
705
+ this.error(
706
+ "An islands-only route's bootstrap reaches the Octane renderer through " +
707
+ chain.join(' -> ') +
708
+ '. Keep its preHydrate hook free of renderer imports.',
709
+ );
710
+ }
711
+ }
712
+ }
488
713
  const hydrationEntry = Object.values(bundle).find(
489
714
  (output) =>
490
715
  output.type === 'chunk' && output.facadeModuleId === RESOLVED_VIRTUAL_HYDRATE_ID,
@@ -547,6 +772,9 @@ export function octane(inlineOptions = {}) {
547
772
  if (id === VIRTUAL_HYDRATE_ID) {
548
773
  return RESOLVED_VIRTUAL_HYDRATE_ID;
549
774
  }
775
+ if (id === VIRTUAL_ISLANDS_ID) {
776
+ return RESOLVED_VIRTUAL_ISLANDS_ID;
777
+ }
550
778
  return null;
551
779
  },
552
780
 
@@ -584,6 +812,25 @@ export function octane(inlineOptions = {}) {
584
812
  );
585
813
  return fs.readFileSync(file, 'utf-8');
586
814
  }
815
+ if (id === RESOLVED_VIRTUAL_ISLANDS_ID) {
816
+ // The shell's modules never load on an islands-only page. The preHydrate
817
+ // hook is its only static entry; islands resolve through their manifest.
818
+ const loaded = isBuild
819
+ ? buildOctaneConfig
820
+ : (octaneConfig ?? (await loadStartupConfig(root))?.config ?? null);
821
+ const preHydrate = loaded?.router.preHydrate;
822
+ const file = write_project_generated_file(
823
+ config,
824
+ 'islands-entry.js',
825
+ create_islands_entry_source({
826
+ configPath: to_vite_root_import(getOctaneConfigPath(root), root),
827
+ staticEntries: preHydrate ? [preHydrate] : [],
828
+ clientBuildId: clientBuild.buildId,
829
+ devClientBuild: !isBuild,
830
+ }),
831
+ );
832
+ return fs.readFileSync(file, 'utf-8');
833
+ }
587
834
  return null;
588
835
  },
589
836
 
@@ -625,6 +872,7 @@ export function octane(inlineOptions = {}) {
625
872
  let initPromise = null;
626
873
  /** @type {number} */
627
874
  let lastConfigErrorMtimeMs = 0;
875
+ const warnIslandsShells = createIslandsShellWarnings(vite, root);
628
876
 
629
877
  async function ensureConfigLoaded() {
630
878
  if (octaneConfig && router) return;
@@ -737,6 +985,10 @@ export function octane(inlineOptions = {}) {
737
985
  next();
738
986
  return;
739
987
  }
988
+ if (freshMatch.route.type === 'render' && freshMatch.route.hydrate === 'islands') {
989
+ // A shell module that fails to parse reports through its SSR load.
990
+ await warnIslandsShells(octaneConfig).catch(() => {});
991
+ }
740
992
 
741
993
  const request = nodeRequestToWebRequest(req, res);
742
994
  const context = createContext(request, freshMatch.params);
@@ -784,6 +1036,8 @@ export function octane(inlineOptions = {}) {
784
1036
  } catch (error) {
785
1037
  console.error('[@octanejs/vite-plugin] Request error:', error);
786
1038
  vite.ssrFixStacktrace(/** @type {Error} */ (error));
1039
+ // sendWebResponse already aborted a response whose headers were sent.
1040
+ if (res.headersSent) return;
787
1041
  res.statusCode = 500;
788
1042
  res.setHeader('Content-Type', 'text/html');
789
1043
  res.end(
@@ -825,8 +1079,10 @@ export function octane(inlineOptions = {}) {
825
1079
  (modules.length > 0 || is_octane_module_path(file))
826
1080
  ) {
827
1081
  clientBuild.invalidate(file);
828
- const entry = this.environment.moduleGraph.getModuleById(RESOLVED_VIRTUAL_HYDRATE_ID);
829
- if (entry) this.environment.moduleGraph.invalidateModule(entry);
1082
+ for (const id of [RESOLVED_VIRTUAL_HYDRATE_ID, RESOLVED_VIRTUAL_ISLANDS_ID]) {
1083
+ const entry = this.environment.moduleGraph.getModuleById(id);
1084
+ if (entry) this.environment.moduleGraph.invalidateModule(entry);
1085
+ }
830
1086
  const ssrModules = server.environments.ssr?.moduleGraph.getModulesByFile(file);
831
1087
  if (ssrModules)
832
1088
  for (const mod of ssrModules) server.environments.ssr.moduleGraph.invalidateModule(mod);
@@ -967,6 +1223,10 @@ export function octane(inlineOptions = {}) {
967
1223
  clientAssetMap,
968
1224
  clientBuild: completedClientBuild,
969
1225
  independentHydrationManifest,
1226
+ islandsEntry:
1227
+ islandsEntryFile === null
1228
+ ? null
1229
+ : hydrationEntryUrl(islandsEntryFile, path.join(root, 'index.html'), config),
970
1230
  ...(webWorkerServer ? { mode: 'webworker' } : null),
971
1231
  // The virtual entry has no filesystem importer, so resolve app-core
972
1232
  // from this package before handing source to Vite. This also works
@@ -1242,6 +1502,8 @@ async function handleRpcRequest(req, res, vite, trustProxy, config) {
1242
1502
  await sendWebResponse(res, response);
1243
1503
  } catch (error) {
1244
1504
  console.error('[@octanejs/vite-plugin] RPC error:', error);
1505
+ // sendWebResponse already aborted a response whose headers were sent.
1506
+ if (res.headersSent) return;
1245
1507
  res.statusCode = 500;
1246
1508
  res.setHeader('Content-Type', 'application/json');
1247
1509
  res.end(JSON.stringify({ error: 'Internal Server Error' }));
@@ -3,6 +3,7 @@ export {
3
3
  SERVER_ONLY_ADAPTER_IDS,
4
4
  create_adapter_browser_stub_source,
5
5
  create_client_entry_source,
6
+ create_islands_entry_source,
6
7
  get_project_generated_dir,
7
8
  normalize_module_reference,
8
9
  to_vite_root_import,
@@ -52,6 +52,83 @@ import {
52
52
  * client-renders the affected boundaries.
53
53
  */
54
54
 
55
+ const CSS_REQUEST = /\.(?:css|less|sass|scss|styl|stylus|pcss|postcss|sss)(?:$|\?)/;
56
+
57
+ /** @param {string} value */
58
+ function escapeAttribute(value) {
59
+ return value.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
60
+ }
61
+
62
+ /**
63
+ * Stylesheets imported anywhere in the already-loaded SSR graph of the shell.
64
+ * @param {ViteDevServer} vite
65
+ * @param {Array<string | undefined>} roots
66
+ * @returns {string[]}
67
+ */
68
+ function collectDevStylesheets(vite, roots) {
69
+ const graph = vite.environments?.ssr?.moduleGraph ?? vite.moduleGraph;
70
+ /** @type {Set<string>} */
71
+ const styles = new Set();
72
+ const seen = new Set();
73
+ /** @param {any} module */
74
+ const visit = (module) => {
75
+ if (!module || seen.has(module)) return;
76
+ seen.add(module);
77
+ if (typeof module.url === 'string' && CSS_REQUEST.test(module.url)) {
78
+ styles.add(module.url);
79
+ return;
80
+ }
81
+ for (const imported of module.importedModules ?? []) visit(imported);
82
+ };
83
+ for (const root of roots) {
84
+ if (!root) continue;
85
+ const file = join(vite.config.root, root.startsWith('/') ? `.${root}` : root);
86
+ for (const module of graph.getModulesByFile(file) ?? []) visit(module);
87
+ }
88
+ return [...styles];
89
+ }
90
+
91
+ /** @type {WeakMap<ViteDevServer, Set<string>>} */
92
+ const reportedShellWitnesses = new WeakMap();
93
+
94
+ /**
95
+ * An islands-only route's shell never loads in the browser. The build checks
96
+ * its source; this checks what a dev render actually reached outside the
97
+ * route's independent islands, through aliases, helpers, and packages the
98
+ * source check cannot follow, and warns once per construct and site.
99
+ *
100
+ * @param {ViteDevServer} vite
101
+ * @param {RenderRoute} route
102
+ * @returns {NonNullable<import('octane/server').RenderOptions['shellWitness']>}
103
+ */
104
+ function createShellWitnessReporter(vite, route) {
105
+ const seen = reportedShellWitnesses.get(vite) ?? new Set();
106
+ reportedShellWitnesses.set(vite, seen);
107
+ return (witness) => {
108
+ const at = witness.location
109
+ ? ` at ${witness.location}`
110
+ : witness.component
111
+ ? ` in ${witness.component}`
112
+ : '';
113
+ const on = witness.tag ? ` on <${witness.tag}>` : '';
114
+ const what =
115
+ witness.kind === 'effect'
116
+ ? `${witness.name}()${at}`
117
+ : witness.kind === 'signal'
118
+ ? `a live signal binding${on}${at}`
119
+ : witness.kind === 'control'
120
+ ? `a controlled "${witness.name}"${on}${at}`
121
+ : `${JSON.stringify(witness.name)}${on}${at}`;
122
+ const message =
123
+ `[@octanejs/vite-plugin] RenderRoute ${JSON.stringify(route.path)} uses hydrate: 'islands', ` +
124
+ `but its shell rendered ${what} outside an independent <Hydrate>. The shell's code never ` +
125
+ 'loads in the browser, so this never runs there; move it into an independent island.';
126
+ if (seen.has(message)) return;
127
+ seen.add(message);
128
+ vite.config.logger.warn(message, { timestamp: true });
129
+ };
130
+ }
131
+
55
132
  /**
56
133
  * @param {RenderRoute} route
57
134
  * @param {Context} context
@@ -162,7 +239,17 @@ export async function handleRenderRoute(
162
239
  clientBuild,
163
240
  streamedSignals,
164
241
  });
165
- const headContent = `<script id="__octane_data" type="application/json"${nonceAttribute(nonce)}>${routeData}</script>`;
242
+ const islands = route.hydrate === 'islands';
243
+ // An islands-only shell never runs its module in the browser, so the CSS it
244
+ // imports must be linked like a production route's stylesheets.
245
+ const shellStyles = islands
246
+ ? collectDevStylesheets(vite, [entryPath, route.layout])
247
+ .map((href) => `<link rel="stylesheet" href="${escapeAttribute(href)}">`)
248
+ .join('\n')
249
+ : '';
250
+ const headContent =
251
+ (shellStyles === '' ? '' : shellStyles + '\n') +
252
+ `<script id="__octane_data" type="application/json"${nonceAttribute(nonce)}>${routeData}</script>`;
166
253
 
167
254
  // Load and process index.html template.
168
255
  const templatePath = join(vite.config.root, 'index.html');
@@ -174,7 +261,11 @@ export async function handleRenderRoute(
174
261
  // Validate the raw SSR template and inject the request-nonced hydrate entry.
175
262
  // The one required head marker is consumed AFTER the render, once the
176
263
  // hoisted metadata is in hand, validation still happens up front here.
177
- const html = injectHydrationEntry(template, '/@id/virtual:octane-hydrate', nonce);
264
+ const html = injectHydrationEntry(
265
+ template,
266
+ islands ? '/@id/virtual:octane-islands' : '/@id/virtual:octane-hydrate',
267
+ nonce,
268
+ );
178
269
 
179
270
  // Start the render. This await resolves at SHELL-ready (so a synchronous
180
271
  // render error still falls into the catch below and produces the dev 500
@@ -201,6 +292,7 @@ export async function handleRenderRoute(
201
292
  onHeadReady(/** @type {string} */ head) {
202
293
  hoistedHead = head;
203
294
  },
295
+ ...(islands ? { shellWitness: createShellWitnessReporter(vite, route) } : {}),
204
296
  signal: context.request.signal,
205
297
  onError(/** @type {unknown} */ error) {
206
298
  if (error instanceof Error) vite.ssrFixStacktrace(error);