@octanejs/vite-plugin 0.1.62 → 0.2.1

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.62",
3
+ "version": "0.2.1",
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.58"
50
+ "@octanejs/app-core": "0.1.1"
51
51
  },
52
52
  "peerDependencies": {
53
53
  "vite": "^8.0.16",
54
- "octane": "^0.7.0"
54
+ "octane": "^0.9.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.7.0"
62
+ "octane": "0.9.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,163 @@ 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
+ * Every rendered document of this app is an islands-only page.
238
+ * @param {ResolvedOctaneConfig | null} config
239
+ * @returns {boolean}
240
+ */
241
+ function has_only_islands_routes(config) {
242
+ const routes = config?.router.routes.filter((route) => route.type === 'render') ?? [];
243
+ return routes.length > 0 && routes.every((route) => route.hydrate === 'islands');
244
+ }
245
+
246
+ /**
247
+ * Check every islands-only route's shell for client work. The shell's modules
248
+ * never load in the browser, so every component it renders outside its
249
+ * independent islands must be static server output. A build fails on the
250
+ * first problem; dev reports each one and keeps serving.
251
+ *
252
+ * @param {(source: string, importer: string) => Promise<{ id: string, external?: boolean | 'absolute' | 'relative' } | null>} resolve
253
+ * @param {string} projectRoot
254
+ * @param {ResolvedOctaneConfig} config
255
+ * @param {(message: string) => void} report
256
+ * @param {(file: string, exports: string[] | null, values: boolean) => ReturnType<typeof analyzeIslandsShell>} [analyze]
257
+ */
258
+ async function checkIslandsShells(
259
+ resolve,
260
+ projectRoot,
261
+ config,
262
+ report,
263
+ analyze = (file, exports, values) =>
264
+ analyzeIslandsShell(fs.readFileSync(file, 'utf-8'), file, exports, { values }),
265
+ ) {
266
+ // Checked exports per module: a rendered export by name, a value passed into
267
+ // JSX as `?name`, which a rendered check of the same export also covers.
268
+ /** @type {Map<string, Set<string> | null>} */
269
+ const checked = new Map();
270
+ for (const route of config.router.routes) {
271
+ if (route.type !== 'render' || route.hydrate !== 'islands') continue;
272
+ const fail = (/** @type {string} */ message) =>
273
+ report(
274
+ `[@octanejs/vite-plugin] RenderRoute ${JSON.stringify(route.path)} uses hydrate: 'islands', but ${message}`,
275
+ );
276
+ if (config.rootBoundary.pending || config.rootBoundary.catch)
277
+ fail('the configured root boundaries need a hydrated root.');
278
+ const exportName = get_route_entry_export_name(route.entry);
279
+ /** @type {Array<[string, string[] | null, boolean]>} */
280
+ const pending = [];
281
+ for (const [
282
+ modulePath,
283
+ exports,
284
+ ] of /** @type {Array<[string | undefined, string[] | null]>} */ ([
285
+ [get_route_entry_path(route.entry), exportName ? [exportName] : null],
286
+ [route.layout, null],
287
+ ])) {
288
+ if (modulePath)
289
+ pending.push([
290
+ path.resolve(projectRoot, modulePath.startsWith('/') ? `.${modulePath}` : modulePath),
291
+ exports,
292
+ false,
293
+ ]);
294
+ }
295
+ while (pending.length > 0) {
296
+ const [file, exports, values] = /** @type {[string, string[] | null, boolean]} */ (
297
+ pending.shift()
298
+ );
299
+ const keys = exports?.map((name) => (values ? `?${name}` : name));
300
+ const seen = checked.get(file);
301
+ if (
302
+ seen === null ||
303
+ (keys !== undefined &&
304
+ keys.every((key) => seen?.has(key) || (key[0] === '?' && seen?.has(key.slice(1)))))
305
+ )
306
+ continue;
307
+ checked.set(file, keys === undefined ? null : new Set([...(seen ?? []), ...keys]));
308
+ if (!is_octane_module_path(file)) {
309
+ // A plain module has no JSX; a value it exports (a string, an asset
310
+ // URL) renders nothing interactive.
311
+ if (!values) fail(`its shell renders ${file}, which Octane cannot check.`);
312
+ continue;
313
+ }
314
+ const result = analyze(file, exports, values);
315
+ if (result.problems.length > 0) {
316
+ const [problem] = result.problems;
317
+ fail(
318
+ `its shell needs client work at ${path.relative(projectRoot, file)}:${problem.line}:${problem.column}: ${problem.message}.`,
319
+ );
320
+ }
321
+ for (const component of result.components) {
322
+ const resolved = await resolve(component.source, file);
323
+ if (!resolved || resolved.external) {
324
+ if (!component.value)
325
+ fail(`its shell renders ${component.source}, which Octane cannot check.`);
326
+ } else
327
+ pending.push([
328
+ resolved.id.split('?')[0],
329
+ [component.exportName],
330
+ component.value === true,
331
+ ]);
332
+ }
333
+ }
334
+ }
335
+ }
336
+
337
+ /**
338
+ * Dev serves an islands-only route whatever its shell does, so an interactive
339
+ * shell would be silently inert there. Warn once per problem, from the same
340
+ * check the build enforces, re-analyzing a module only when it changes.
341
+ *
342
+ * @param {ViteDevServer} server
343
+ * @param {string} projectRoot
344
+ */
345
+ function createIslandsShellWarnings(server, projectRoot) {
346
+ /** @type {Map<string, { mtimeMs: number, result: ReturnType<typeof analyzeIslandsShell> }>} */
347
+ const analyses = new Map();
348
+ /** @type {Set<string>} */
349
+ let warned = new Set();
350
+ /** @param {ResolvedOctaneConfig} config */
351
+ return async (config) => {
352
+ /** @type {Set<string>} */
353
+ const current = new Set();
354
+ await checkIslandsShells(
355
+ async (source, importer) =>
356
+ (await server.environments.ssr.pluginContainer.resolveId(source, importer)) ?? null,
357
+ projectRoot,
358
+ config,
359
+ (message) => current.add(message),
360
+ (file, exports, values) => {
361
+ const key = `${file}\0${exports?.join(',') ?? '*'}\0${values}`;
362
+ const mtimeMs = fs.statSync(file).mtimeMs;
363
+ const cached = analyses.get(key);
364
+ if (cached?.mtimeMs === mtimeMs) return cached.result;
365
+ const result = analyzeIslandsShell(fs.readFileSync(file, 'utf-8'), file, exports, {
366
+ values,
367
+ });
368
+ analyses.set(key, { mtimeMs, result });
369
+ return result;
370
+ },
371
+ );
372
+ for (const message of current)
373
+ if (!warned.has(message))
374
+ server.config.logger.warn(
375
+ `${message} A production build fails until the shell is static.`,
376
+ { timestamp: true },
377
+ );
378
+ warned = current;
379
+ };
380
+ }
381
+
218
382
  /**
219
383
  * Every module path the server can name in #__octane_data — page entries,
220
384
  * layouts, the preHydrate hook, and root boundaries. The generated hydrate
@@ -297,6 +461,8 @@ export function octane(inlineOptions = {}) {
297
461
  let staticEntries = [];
298
462
  /** @type {Record<string, string>} Static module path → emitted client chunk file */
299
463
  let staticEntryFiles = Object.create(null);
464
+ /** @type {string | null} Emitted renderer-free entry for islands-only routes */
465
+ let islandsEntryFile = null;
300
466
  /** @type {Set<string>} Vite-root paths of modules containing `module server` */
301
467
  const serverModuleModules = new Set();
302
468
  /** @type {ViteDevServer | undefined} */
@@ -304,8 +470,10 @@ export function octane(inlineOptions = {}) {
304
470
  const clientBuild = createClientBuildState((buildId) => {
305
471
  if (!devServer) return;
306
472
  const clientGraph = devServer.environments.client.moduleGraph;
307
- const entry = clientGraph.getModuleById(RESOLVED_VIRTUAL_HYDRATE_ID);
308
- if (entry) clientGraph.invalidateModule(entry);
473
+ for (const id of [RESOLVED_VIRTUAL_HYDRATE_ID, RESOLVED_VIRTUAL_ISLANDS_ID]) {
474
+ const entry = clientGraph.getModuleById(id);
475
+ if (entry) clientGraph.invalidateModule(entry);
476
+ }
309
477
  devServer.ws.send('octane:independent-hydration', { buildId, enabled: false });
310
478
  devServer.ws.send({ type: 'full-reload' });
311
479
  });
@@ -437,8 +605,27 @@ export function octane(inlineOptions = {}) {
437
605
  return { relative: true };
438
606
  }
439
607
  };
608
+ // Only the renderer consumes the signal Action frame. Islands-only
609
+ // documents load signals without it, so this condition moves the
610
+ // frame and its candidate producers from the signal modules into the
611
+ // renderer (octane's action-capability.ts). Either placement is
612
+ // complete; it chooses which pages pay, so mixed apps keep the default.
613
+ /** @type {UserConfig['resolve']} */
614
+ let resolve;
615
+ if (has_only_islands_routes(buildOctaneConfig)) {
616
+ const { defaultClientConditions } = await import('vite');
617
+ // Vite concatenates plugin conditions onto the user's own, and
618
+ // replaces its defaults only when none were configured.
619
+ resolve = {
620
+ conditions: [
621
+ ...(userConfig.resolve?.conditions ? [] : defaultClientConditions),
622
+ 'octane-islands',
623
+ ],
624
+ };
625
+ }
440
626
  return {
441
627
  ...base,
628
+ ...(resolve ? { resolve } : null),
442
629
  build: buildConfig,
443
630
  experimental: {
444
631
  ...userConfig.experimental,
@@ -458,7 +645,7 @@ export function octane(inlineOptions = {}) {
458
645
  * generated hydrate entry maps them as STATIC dynamic imports Rollup can
459
646
  * chunk and hash.
460
647
  */
461
- buildStart() {
648
+ async buildStart() {
462
649
  if (!isBuild || isSSRBuild || !has_route_config(buildOctaneConfig)) return;
463
650
  clientBuildWritten = false;
464
651
  clientBuild.begin();
@@ -473,6 +660,21 @@ export function octane(inlineOptions = {}) {
473
660
  name: 'octane-hydrate',
474
661
  preserveSignature: 'strict',
475
662
  });
663
+ islandsEntryFile = null;
664
+ if (has_islands_route(buildOctaneConfig)) {
665
+ await checkIslandsShells(
666
+ (source, importer) => this.resolve(source, importer, { skipSelf: true }),
667
+ root,
668
+ /** @type {ResolvedOctaneConfig} */ (buildOctaneConfig),
669
+ (message) => this.error(message),
670
+ );
671
+ this.emitFile({
672
+ type: 'chunk',
673
+ id: VIRTUAL_ISLANDS_ID,
674
+ name: 'octane-islands',
675
+ preserveSignature: 'strict',
676
+ });
677
+ }
476
678
  },
477
679
 
478
680
  writeBundle() {
@@ -483,8 +685,60 @@ export function octane(inlineOptions = {}) {
483
685
  * Preserve the source-to-file relation that Vite's manifest cannot express
484
686
  * when Rolldown promotes a dynamic route entry into a shared chunk.
485
687
  */
486
- generateBundle(_options, bundle) {
688
+ async generateBundle(_options, bundle) {
487
689
  if (!isBuild || isSSRBuild || !has_route_config(buildOctaneConfig)) return;
690
+ if (has_islands_route(buildOctaneConfig)) {
691
+ const islandsEntry = Object.values(bundle).find(
692
+ (output) =>
693
+ output.type === 'chunk' && output.facadeModuleId === RESOLVED_VIRTUAL_ISLANDS_ID,
694
+ );
695
+ if (!islandsEntry || islandsEntry.type !== 'chunk') {
696
+ this.error('The Octane islands entry must retain its own emitted facade.');
697
+ }
698
+ islandsEntryFile = islandsEntry.fileName;
699
+ // Fail closed: an islands-only page loads this entry, the hydration
700
+ // modules it imports lazily, and its preHydrate hook. None may reach the
701
+ // renderer; an island that needs it loads it through its own chunk.
702
+ const renderer = new Set();
703
+ for (const request of ['octane', 'octane/internal/client']) {
704
+ const resolved = await this.resolve(request, undefined, { skipSelf: true });
705
+ if (resolved) renderer.add(resolved.id);
706
+ }
707
+ const preHydrate = /** @type {ResolvedOctaneConfig} */ (buildOctaneConfig).router
708
+ .preHydrate;
709
+ const roots = [RESOLVED_VIRTUAL_ISLANDS_ID];
710
+ if (preHydrate) {
711
+ const resolved = await this.resolve(preHydrate, undefined, { skipSelf: true });
712
+ if (resolved) roots.push(resolved.id);
713
+ }
714
+ const seen = new Set();
715
+ /** @type {string[]} */
716
+ const chain = [];
717
+ /** @param {string} id @returns {boolean} */
718
+ const reaches = (id) => {
719
+ if (seen.has(id)) return false;
720
+ seen.add(id);
721
+ chain.push(id);
722
+ if (renderer.has(id)) return true;
723
+ const info = this.getModuleInfo(id);
724
+ for (const imported of [
725
+ ...(info?.importedIds ?? []),
726
+ ...(info?.dynamicallyImportedIds ?? []),
727
+ ])
728
+ if (reaches(imported)) return true;
729
+ chain.pop();
730
+ return false;
731
+ };
732
+ for (const id of roots) {
733
+ if (reaches(id)) {
734
+ this.error(
735
+ "An islands-only route's bootstrap reaches the Octane renderer through " +
736
+ chain.join(' -> ') +
737
+ '. Keep its preHydrate hook free of renderer imports.',
738
+ );
739
+ }
740
+ }
741
+ }
488
742
  const hydrationEntry = Object.values(bundle).find(
489
743
  (output) =>
490
744
  output.type === 'chunk' && output.facadeModuleId === RESOLVED_VIRTUAL_HYDRATE_ID,
@@ -547,6 +801,9 @@ export function octane(inlineOptions = {}) {
547
801
  if (id === VIRTUAL_HYDRATE_ID) {
548
802
  return RESOLVED_VIRTUAL_HYDRATE_ID;
549
803
  }
804
+ if (id === VIRTUAL_ISLANDS_ID) {
805
+ return RESOLVED_VIRTUAL_ISLANDS_ID;
806
+ }
550
807
  return null;
551
808
  },
552
809
 
@@ -584,6 +841,25 @@ export function octane(inlineOptions = {}) {
584
841
  );
585
842
  return fs.readFileSync(file, 'utf-8');
586
843
  }
844
+ if (id === RESOLVED_VIRTUAL_ISLANDS_ID) {
845
+ // The shell's modules never load on an islands-only page. The preHydrate
846
+ // hook is its only static entry; islands resolve through their manifest.
847
+ const loaded = isBuild
848
+ ? buildOctaneConfig
849
+ : (octaneConfig ?? (await loadStartupConfig(root))?.config ?? null);
850
+ const preHydrate = loaded?.router.preHydrate;
851
+ const file = write_project_generated_file(
852
+ config,
853
+ 'islands-entry.js',
854
+ create_islands_entry_source({
855
+ configPath: to_vite_root_import(getOctaneConfigPath(root), root),
856
+ staticEntries: preHydrate ? [preHydrate] : [],
857
+ clientBuildId: clientBuild.buildId,
858
+ devClientBuild: !isBuild,
859
+ }),
860
+ );
861
+ return fs.readFileSync(file, 'utf-8');
862
+ }
587
863
  return null;
588
864
  },
589
865
 
@@ -625,6 +901,7 @@ export function octane(inlineOptions = {}) {
625
901
  let initPromise = null;
626
902
  /** @type {number} */
627
903
  let lastConfigErrorMtimeMs = 0;
904
+ const warnIslandsShells = createIslandsShellWarnings(vite, root);
628
905
 
629
906
  async function ensureConfigLoaded() {
630
907
  if (octaneConfig && router) return;
@@ -737,6 +1014,10 @@ export function octane(inlineOptions = {}) {
737
1014
  next();
738
1015
  return;
739
1016
  }
1017
+ if (freshMatch.route.type === 'render' && freshMatch.route.hydrate === 'islands') {
1018
+ // A shell module that fails to parse reports through its SSR load.
1019
+ await warnIslandsShells(octaneConfig).catch(() => {});
1020
+ }
740
1021
 
741
1022
  const request = nodeRequestToWebRequest(req, res);
742
1023
  const context = createContext(request, freshMatch.params);
@@ -827,8 +1108,10 @@ export function octane(inlineOptions = {}) {
827
1108
  (modules.length > 0 || is_octane_module_path(file))
828
1109
  ) {
829
1110
  clientBuild.invalidate(file);
830
- const entry = this.environment.moduleGraph.getModuleById(RESOLVED_VIRTUAL_HYDRATE_ID);
831
- if (entry) this.environment.moduleGraph.invalidateModule(entry);
1111
+ for (const id of [RESOLVED_VIRTUAL_HYDRATE_ID, RESOLVED_VIRTUAL_ISLANDS_ID]) {
1112
+ const entry = this.environment.moduleGraph.getModuleById(id);
1113
+ if (entry) this.environment.moduleGraph.invalidateModule(entry);
1114
+ }
832
1115
  const ssrModules = server.environments.ssr?.moduleGraph.getModulesByFile(file);
833
1116
  if (ssrModules)
834
1117
  for (const mod of ssrModules) server.environments.ssr.moduleGraph.invalidateModule(mod);
@@ -969,6 +1252,10 @@ export function octane(inlineOptions = {}) {
969
1252
  clientAssetMap,
970
1253
  clientBuild: completedClientBuild,
971
1254
  independentHydrationManifest,
1255
+ islandsEntry:
1256
+ islandsEntryFile === null
1257
+ ? null
1258
+ : hydrationEntryUrl(islandsEntryFile, path.join(root, 'index.html'), config),
972
1259
  ...(webWorkerServer ? { mode: 'webworker' } : null),
973
1260
  // The virtual entry has no filesystem importer, so resolve app-core
974
1261
  // from this package before handing source to Vite. This also works
@@ -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);