@withl5e/l5e 1.1.0-alpha.0 → 1.1.0-alpha.2

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.
@@ -5,7 +5,22 @@ import type { Manifest } from 'vite';
5
5
  import { withAssetBase } from './global-style';
6
6
 
7
7
  /** Preserve the emitted module boundaries chosen by the application's build. */
8
- export function createScriptBundlePolicy(manifest: Manifest, distClientDir: string, base = '/') {
8
+ export interface ChunkingReport {
9
+ mode?: string;
10
+ chunks?: Array<{
11
+ file: string;
12
+ kind: string;
13
+ canonicalShared?: boolean;
14
+ canonicalGlobal?: boolean;
15
+ }>;
16
+ }
17
+
18
+ export function createScriptBundlePolicy(
19
+ manifest: Manifest,
20
+ distClientDir: string,
21
+ base = '/',
22
+ report?: ChunkingReport,
23
+ ) {
9
24
  const directory = path.resolve(distClientDir);
10
25
  // Rolldown resolves entry importers through symlinks/junctions. Use the same
11
26
  // physical directory for identity while retaining the caller's output scope.
@@ -13,6 +28,7 @@ export function createScriptBundlePolicy(manifest: Manifest, distClientDir: stri
13
28
  const files = new Set<string>();
14
29
  const preserved = new Set<string>();
15
30
  const staticImports = new Map<string, string[]>();
31
+ const lazy = new Set<string>();
16
32
  const jsEntries = Object.entries(manifest).filter(([, entry]) => /\.[cm]?js$/.test(entry.file));
17
33
 
18
34
  function absoluteFile(file: string) {
@@ -29,6 +45,17 @@ export function createScriptBundlePolicy(manifest: Manifest, distClientDir: stri
29
45
  return absolute;
30
46
  }
31
47
 
48
+ const shared = new Set(
49
+ (report?.chunks || [])
50
+ .filter((chunk) => report?.mode === 'compact' && chunk.canonicalShared)
51
+ .map((chunk) => absoluteFile(chunk.file)),
52
+ );
53
+ const globalOwned = new Set(
54
+ (report?.chunks || [])
55
+ .filter((chunk) => report?.mode === 'compact' && chunk.canonicalGlobal)
56
+ .map((chunk) => absoluteFile(chunk.file)),
57
+ );
58
+
32
59
  for (const [key, entry] of jsEntries) {
33
60
  const file = absoluteFile(entry.file);
34
61
  files.add(file);
@@ -57,21 +84,91 @@ export function createScriptBundlePolicy(manifest: Manifest, distClientDir: stri
57
84
  );
58
85
  }
59
86
 
87
+
88
+ // A runtime page bundle keeps dynamic roots and their static closure as emitted
89
+ // assets. This preserves download/init boundaries while allowing the page's
90
+ // transformed preload helper to start the known closure in parallel.
91
+ const staticallyInitial = new Set<string>();
92
+ const visitInitial = (key: string) => {
93
+ const entry = manifest[key];
94
+ if (!entry || !/\.[cm]?js$/.test(entry.file)) return;
95
+ const file = absoluteFile(entry.file);
96
+ if (staticallyInitial.has(file)) return;
97
+ staticallyInitial.add(file);
98
+ for (const dependency of entry.imports || []) visitInitial(dependency);
99
+ };
100
+ for (const [key, entry] of jsEntries) {
101
+ if (entry.isEntry && !entry.isDynamicEntry) visitInitial(key);
102
+ }
103
+ const visitLazy = (key: string) => {
104
+ const entry = manifest[key];
105
+ if (!entry || !/\.[cm]?js$/.test(entry.file)) return;
106
+ const file = absoluteFile(entry.file);
107
+ if (lazy.has(file) || staticallyInitial.has(file)) return;
108
+ lazy.add(file);
109
+ for (const dependency of entry.imports || []) visitLazy(dependency);
110
+ };
111
+ for (const [, entry] of jsEntries) {
112
+ for (const key of entry.dynamicImports || []) visitLazy(key);
113
+ }
114
+
60
115
  const cacheKey = createHash('sha256')
61
116
  .update(JSON.stringify([directory, assetDirectory, base, manifest]))
62
117
  .digest('hex');
63
118
 
64
119
  return {
65
120
  cacheKey,
121
+ compact: report?.mode === 'compact',
66
122
  directory,
67
123
  fileForScript(script: string) {
68
- const file = absoluteFile(script.replace(/^\/+/, ''));
124
+ const normalizedBase = base === '/' ? '/' : `/${base.replace(/^\/+|\/+$/g, '')}/`;
125
+ const relative = script.startsWith(normalizedBase)
126
+ ? script.slice(normalizedBase.length)
127
+ : script.replace(/^\/+/, '');
128
+ const file = absoluteFile(relative);
69
129
  if (!files.has(file)) throw new Error(`[bundler] Script is missing from manifest: ${script}`);
70
130
  return file;
71
131
  },
132
+ fileForAssetUrl(url: string) {
133
+ const normalizedBase = base === '/' ? '/' : `/${base.replace(/^\/+|\/+$/g, '')}/`;
134
+ const relative = url.startsWith(normalizedBase)
135
+ ? url.slice(normalizedBase.length)
136
+ : url.replace(/^\/+/, '');
137
+ const file = absoluteFile(relative);
138
+ return files.has(file) ? file : undefined;
139
+ },
72
140
  isPreserved(file: string) {
73
141
  return preserved.has(file);
74
142
  },
143
+ isShared(file: string) {
144
+ return shared.has(file);
145
+ },
146
+ isGlobalOwned(file: string) {
147
+ return globalOwned.has(file);
148
+ },
149
+ isLazy(file: string) {
150
+ return lazy.has(file);
151
+ },
152
+ staticClosure(roots: string[]) {
153
+ const closure = new Set<string>();
154
+ const visit = (file: string) => {
155
+ if (closure.has(file)) return;
156
+ closure.add(file);
157
+ for (const dependency of staticImports.get(file) || []) visit(dependency);
158
+ };
159
+ roots.forEach(visit);
160
+ return closure;
161
+ },
162
+ sharedScripts() {
163
+ return [...shared].map((file) =>
164
+ withAssetBase(base, path.relative(assetDirectory, file).replace(/\\/g, '/')),
165
+ );
166
+ },
167
+ globalOwnedScripts() {
168
+ return [...globalOwned].map((file) =>
169
+ withAssetBase(base, path.relative(assetDirectory, file).replace(/\\/g, '/')),
170
+ );
171
+ },
75
172
  inlineableRoots(roots: string[]) {
76
173
  // External ESM imports execute before any inlined body. Isolate prefix
77
174
  // roots in runtime chunks when a later root introduces a dependency effect;
@@ -10,7 +10,11 @@ import { createContext, type MiddlewareHandler, type RewritePayload } from '../m
10
10
  import { bundleCss, bundleScripts, getBundledFile } from './bundler';
11
11
  import type { RenderResult, RequestInfo } from './entry-server';
12
12
  import { resolveGlobalStyleHref, withAssetBase } from './global-style';
13
- import { createScriptBundlePolicy, type ScriptBundlePolicy } from './script-bundle-policy';
13
+ import {
14
+ createScriptBundlePolicy,
15
+ type ChunkingReport,
16
+ type ScriptBundlePolicy,
17
+ } from './script-bundle-policy';
14
18
  import type { Manifest } from 'vite';
15
19
  import { escapeProp } from './render';
16
20
  import { createHeadersFromExpressRequest, parseCookies } from './request';
@@ -281,6 +285,55 @@ async function createPageResponse({
281
285
  }
282
286
 
283
287
  if (isProduction && manifest) {
288
+ let compactSharedUrl: string | undefined;
289
+ let compactGlobalUrl: string | undefined;
290
+ if (scriptPolicy?.compact) {
291
+ const sharedScripts = scriptPolicy.sharedScripts();
292
+ if (sharedScripts.length > 1) {
293
+ throw new Error('[l5e chunking] Compact build has more than one canonical shared root.');
294
+ }
295
+ if (sharedScripts.length === 1) {
296
+ const bundledShared = await bundleScripts(sharedScripts, distClientDir, scriptPolicy, {
297
+ compact: true,
298
+ name: 'shared',
299
+ });
300
+ if (!bundledShared.filename) {
301
+ throw new Error('[l5e chunking] Compact shared bundle failed.');
302
+ }
303
+ compactSharedUrl = withAssetBase(assetBase, bundledShared.filename);
304
+ }
305
+ const compactGlobalEntry = manifest['src/client.global.ts'];
306
+ if (compactGlobalEntry?.file) {
307
+ const bundledGlobal = await bundleScripts(
308
+ [`/${compactGlobalEntry.file}`],
309
+ distClientDir,
310
+ scriptPolicy,
311
+ {
312
+ compact: true,
313
+ name: 'global',
314
+ sharedUrl: compactSharedUrl,
315
+ exportScripts: scriptPolicy.globalOwnedScripts(),
316
+ },
317
+ );
318
+ if (!bundledGlobal.filename) {
319
+ throw new Error('[l5e chunking] Compact global bundle failed.');
320
+ }
321
+ compactGlobalUrl = withAssetBase(assetBase, bundledGlobal.filename);
322
+ globalScripts.push(compactGlobalUrl);
323
+ }
324
+ const imports: Record<string, string> = {};
325
+ if (compactSharedUrl) {
326
+ for (const original of sharedScripts) imports[original] = compactSharedUrl;
327
+ }
328
+ if (compactGlobalUrl) {
329
+ for (const original of scriptPolicy.globalOwnedScripts()) {
330
+ imports[original] = compactGlobalUrl;
331
+ }
332
+ }
333
+ if (Object.keys(imports).length > 0) {
334
+ extraHead += `<script type="importmap">${serialize({ imports })}</script>`;
335
+ }
336
+ }
284
337
  scriptSrcList = scriptSrcList.filter(
285
338
  (src) => src.replace(/^\//, '') !== 'src/client.global.ts',
286
339
  );
@@ -304,6 +357,28 @@ async function createPageResponse({
304
357
  return { file: entry.file };
305
358
  }
306
359
 
360
+ function activationPlan(entryKeys: string[], excluded = new Set<string>()) {
361
+ const js = new Set<string>();
362
+ const css = new Set<string>();
363
+ const visited = new Set<string>();
364
+ const visit = (entryKey: string) => {
365
+ if (visited.has(entryKey)) return;
366
+ visited.add(entryKey);
367
+ const entry = manifest![entryKey];
368
+ if (!entry) return;
369
+ for (const file of entry.css || []) css.add(withAssetBase(assetBase, file));
370
+ if (!excluded.has(entryKey) && entry.file && /\.[cm]?js$/.test(entry.file)) {
371
+ const absolute = scriptPolicy!.fileForScript(`/${entry.file}`);
372
+ if (!scriptPolicy!.isShared(absolute) && !scriptPolicy!.isGlobalOwned(absolute)) {
373
+ js.add(withAssetBase(assetBase, entry.file));
374
+ }
375
+ }
376
+ for (const dependency of entry.imports || []) visit(dependency);
377
+ };
378
+ entryKeys.forEach(visit);
379
+ return { js: [...js], css: [...css] };
380
+ }
381
+
307
382
  const mappedScripts: string[] = [];
308
383
  for (const src of scriptSrcList) {
309
384
  const entryKey = src.replace(/^\//, '');
@@ -322,12 +397,97 @@ async function createPageResponse({
322
397
  }
323
398
 
324
399
  if (mappedScripts.length > 0) {
325
- const bundledScript = await bundleScripts(mappedScripts, distClientDir, scriptPolicy);
400
+ const bundledScript = await bundleScripts(mappedScripts, distClientDir, scriptPolicy, {
401
+ compact: scriptPolicy?.compact,
402
+ name: 'bundle',
403
+ sharedUrl: compactSharedUrl,
404
+ globalUrl: compactGlobalUrl,
405
+ });
326
406
  scriptSrcList = bundledScript.filename
327
407
  ? [withAssetBase(assetBase, bundledScript.filename)]
328
408
  : mappedScripts.map((file) => withAssetBase(assetBase, file));
329
409
  }
330
410
 
411
+ if (scriptPolicy?.compact && islandEntries.length > 0) {
412
+ const rendererEntry = Object.entries(manifest).find(
413
+ ([key, entry]) => entry.isEntry && key.includes('l5e-compact-renderer'),
414
+ );
415
+ if (!rendererEntry?.[1].file) {
416
+ throw new Error('[l5e chunking] Compact renderer entry is missing from the manifest.');
417
+ }
418
+ const rendererOwnedKeys = new Set<string>();
419
+ rendererOwnedKeys.add(rendererEntry[0]);
420
+ const rendererDependencies = (rendererEntry[1].imports || []).filter((dependency: string) =>
421
+ manifest[dependency]?.file?.includes('compact-renderer'),
422
+ );
423
+ if (rendererDependencies.length !== 1) {
424
+ throw new Error(
425
+ `[l5e chunking] Compact renderer expected one canonical export chunk; found ${rendererDependencies.length}. Multiple chunks can have colliding emitted export aliases.`,
426
+ );
427
+ }
428
+ rendererOwnedKeys.add(rendererDependencies[0]);
429
+ const bundledRenderer = await bundleScripts(
430
+ [`/${rendererEntry[1].file}`],
431
+ distClientDir,
432
+ scriptPolicy,
433
+ {
434
+ compact: true,
435
+ name: 'renderer',
436
+ sharedUrl: compactSharedUrl,
437
+ globalUrl: compactGlobalUrl,
438
+ inlineStaticRootClosure: true,
439
+ exportScripts: [...rendererOwnedKeys]
440
+ .filter((key) => key !== rendererEntry[0])
441
+ .map((key) => `/${manifest[key].file}`),
442
+ },
443
+ );
444
+ if (!bundledRenderer.filename) {
445
+ throw new Error('[l5e chunking] Compact renderer bundle failed.');
446
+ }
447
+ const rendererUrl = withAssetBase(assetBase, bundledRenderer.filename);
448
+ const rendererAliases = Object.fromEntries(
449
+ [...rendererOwnedKeys]
450
+ .filter((key) => manifest[key]?.file)
451
+ .map((key) => [`/${manifest[key].file}`, rendererUrl]),
452
+ );
453
+ const rendererImports = Object.fromEntries(
454
+ [...rendererOwnedKeys]
455
+ .filter((key) => manifest[key]?.file)
456
+ .map((key) => [withAssetBase(assetBase, manifest[key].file), rendererUrl]),
457
+ );
458
+ if (Object.keys(rendererImports).length > 0) {
459
+ extraHead += `<script type="importmap">${serialize({ imports: rendererImports })}</script>`;
460
+ }
461
+ const islandMap: Record<
462
+ string,
463
+ { module: string; renderer: string; js: string[]; css: string[] }
464
+ > = {};
465
+ for (const island of islandEntries) {
466
+ const entry = manifest[island.src];
467
+ if (!entry?.file) continue;
468
+ const bundledIsland = await bundleScripts([`/${entry.file}`], distClientDir, scriptPolicy, {
469
+ compact: true,
470
+ name: 'island',
471
+ sharedUrl: compactSharedUrl,
472
+ globalUrl: compactGlobalUrl,
473
+ inlineStaticRootClosure: true,
474
+ externalScripts: rendererAliases,
475
+ });
476
+ if (!bundledIsland.filename) {
477
+ throw new Error(`[l5e chunking] Compact island bundle failed: ${island.src}`);
478
+ }
479
+ const islandUrl = withAssetBase(assetBase, bundledIsland.filename);
480
+ const plan = activationPlan([island.src], rendererOwnedKeys);
481
+ islandMap[island.key] = {
482
+ module: islandUrl,
483
+ renderer: rendererUrl,
484
+ js: [rendererUrl, islandUrl],
485
+ css: plan.css,
486
+ };
487
+ }
488
+ islandRegistryScript = `<script>Object.assign(window.__L5E_ISLANDS__||={},${serialize(islandMap)});window.__L5E_BOOT_ISLANDS__?.()</script>`;
489
+ }
490
+
331
491
  if (cssFiles.size > 0) {
332
492
  const bundledCss = await bundleCss(
333
493
  [...cssFiles].map((file) => `/${file}`),
@@ -349,15 +509,20 @@ async function createPageResponse({
349
509
  });
350
510
  }
351
511
  if (globalEntry.file) {
352
- globalScripts.push(withAssetBase(assetBase, globalEntry.file));
512
+ if (scriptPolicy?.compact) {
513
+ // The compact global is built before the page bundle so page imports
514
+ // can target its live export bridge.
515
+ } else {
516
+ globalScripts.push(withAssetBase(assetBase, globalEntry.file));
517
+ }
353
518
  }
354
519
  }
355
520
 
356
- for (const file of preloadFiles) {
521
+ for (const file of scriptPolicy?.compact ? [] : preloadFiles) {
357
522
  extraHead += `<link rel="modulepreload" crossorigin href="${escapeProp(withAssetBase(assetBase, file))}">`;
358
523
  }
359
524
 
360
- if (islandEntries.length > 0) {
525
+ if (islandEntries.length > 0 && !scriptPolicy?.compact) {
361
526
  const islandMap: Record<string, string> = {};
362
527
  for (const island of islandEntries) {
363
528
  const entry = manifest[island.src];
@@ -497,7 +662,17 @@ export async function createServer(options: ServerOptions = {}): Promise<ServerC
497
662
  let scriptPolicy: ScriptBundlePolicy | undefined;
498
663
  if (productionManifest) {
499
664
  try {
500
- scriptPolicy = createScriptBundlePolicy(productionManifest, distClientDir, base);
665
+ let chunkingReport: ChunkingReport | undefined;
666
+ const reportPath = path.join(distClientDir, '.vite/l5e-chunks.json');
667
+ if (existsSync(reportPath)) {
668
+ chunkingReport = JSON.parse(await fs.readFile(reportPath, 'utf-8'));
669
+ }
670
+ scriptPolicy = createScriptBundlePolicy(
671
+ productionManifest,
672
+ distClientDir,
673
+ base,
674
+ chunkingReport,
675
+ );
501
676
  } catch (error) {
502
677
  console.warn('[bundler] Invalid build manifest; serving original script entries.', error);
503
678
  }
@@ -536,11 +711,18 @@ export async function createServer(options: ServerOptions = {}): Promise<ServerC
536
711
  // Route để serve bundled files từ memory map
537
712
  // Đặt route này trước route HTML để catch request trước
538
713
  app.get(
539
- withAssetBase(base, 'bundle-:hash.:ext'),
714
+ [
715
+ withAssetBase(base, 'global-:hash.:ext'),
716
+ withAssetBase(base, 'bundle-:hash.:ext'),
717
+ withAssetBase(base, 'shared-:hash.:ext'),
718
+ withAssetBase(base, 'renderer-:hash.:ext'),
719
+ withAssetBase(base, 'island-:hash.:ext'),
720
+ ],
540
721
  async (req: ExpressRequest, res: ExpressResponse) => {
541
722
  try {
542
723
  const { hash, ext } = req.params;
543
- const filename = `bundle-${hash}.${ext}`;
724
+ const kind = path.basename(req.path).split('-', 1)[0];
725
+ const filename = `${kind}-${hash}.${ext}`;
544
726
  const bundledFile = getBundledFile(filename);
545
727
 
546
728
  if (!bundledFile) {
@@ -20,6 +20,7 @@ const VIRTUAL_L5E_SSR_ENTRY = 'virtual:l5e-ssr-entry';
20
20
  const VIRTUAL_L5E_GLOBAL_LOADER = 'virtual:l5e-global-loader';
21
21
  const VIRTUAL_L5E_ISLAND_STRATEGIES = 'virtual:l5e-island-strategies';
22
22
  const VIRTUAL_L5E_ISLANDS = 'virtual:l5e-islands';
23
+ const VIRTUAL_L5E_COMPACT_RENDERER = 'virtual:l5e-compact-renderer';
23
24
  const VIRTUAL_L5E_ACTIONS = 'virtual:l5e-actions';
24
25
  const VIRTUAL_L5E_MIDDLEWARE = 'virtual:l5e-middleware';
25
26
 
@@ -575,9 +576,28 @@ export function coreVite(options: CoreViteOptions = {}): Plugin {
575
576
  typeof existingInput === 'object' && !Array.isArray(existingInput)
576
577
  ? { ...discovered.input, ...existingInput }
577
578
  : discovered.input;
579
+ if (options.chunking && options.chunking.mode === 'compact') {
580
+ mergedInput['l5e-compact-renderer'] = VIRTUAL_L5E_COMPACT_RENDERER;
581
+ }
578
582
 
579
583
  return {
580
584
  build: {
585
+ ...(options.chunking && options.chunking.mode === 'compact'
586
+ ? {
587
+ modulePreload: {
588
+ resolveDependencies: (
589
+ _filename: string,
590
+ dependencies: string[],
591
+ context: { hostType: 'html' | 'js' },
592
+ ) =>
593
+ context.hostType === 'html'
594
+ ? dependencies.filter(
595
+ (dependency) => !/\.[cm]?js(?:\?|$)/.test(dependency),
596
+ )
597
+ : dependencies,
598
+ },
599
+ }
600
+ : {}),
581
601
  rolldownOptions: {
582
602
  ...userConfig.build?.rolldownOptions,
583
603
  input: Object.keys(mergedInput).length > 0 ? mergedInput : undefined,
@@ -604,6 +624,10 @@ export function coreVite(options: CoreViteOptions = {}): Plugin {
604
624
  chunkFileNames: (chunk) =>
605
625
  chunk.name.startsWith('shared-')
606
626
  ? 'assets/[name]-[hash].js'
627
+ : chunk.name === 'shared'
628
+ ? 'assets/shared-[hash].js'
629
+ : chunk.name === 'lazy-react-runtime'
630
+ ? 'assets/lazy-react-runtime-[hash].js'
607
631
  : chunk.isDynamicEntry
608
632
  ? 'assets/bundle-[name]-[hash].js'
609
633
  : 'assets/shared-[name]-[hash].js',
@@ -634,6 +658,9 @@ export function coreVite(options: CoreViteOptions = {}): Plugin {
634
658
  if (id === VIRTUAL_L5E_ISLANDS) {
635
659
  return '\0' + VIRTUAL_L5E_ISLANDS;
636
660
  }
661
+ if (id === VIRTUAL_L5E_COMPACT_RENDERER) {
662
+ return '\0' + VIRTUAL_L5E_COMPACT_RENDERER;
663
+ }
637
664
  if (id === VIRTUAL_L5E_ACTIONS) {
638
665
  return '\0' + VIRTUAL_L5E_ACTIONS;
639
666
  }
@@ -643,18 +670,18 @@ export function coreVite(options: CoreViteOptions = {}): Plugin {
643
670
  return null;
644
671
  },
645
672
 
646
- async transform(code, id, options) {
673
+ async transform(code, id, transformOptions) {
647
674
  // Auto-inject island runtime into client.global.ts so it's always loaded globally
648
- if (id.replace(/\\/g, '/').endsWith('src/client.global.ts') && !options?.ssr) {
675
+ if (id.replace(/\\/g, '/').endsWith('src/client.global.ts') && !transformOptions?.ssr) {
649
676
  return {
650
- code: `import '@withl5e/l5e/island/runtime';\n${code}`,
677
+ code: `import '@withl5e/l5e/island/${options.chunking && options.chunking.mode === 'compact' ? 'compact-runtime' : 'runtime'}';\n${code}`,
651
678
  map: null,
652
679
  };
653
680
  }
654
681
 
655
682
  // Client-side action transform: replace defineAction exports with fetch stubs
656
683
  // Supports actions anywhere under src/ (e.g., src/views/*, src/features/*, etc.)
657
- if (!options?.ssr) {
684
+ if (!transformOptions?.ssr) {
658
685
  const normalizedId = id.replace(/\\/g, '/');
659
686
  const actionMatch = normalizedId.match(/\/src\/(.+)\/actions\.(ts|tsx)$/);
660
687
  if (actionMatch) {
@@ -752,7 +779,7 @@ export function coreVite(options: CoreViteOptions = {}): Plugin {
752
779
  // Server side có thể access tất cả env variables
753
780
  // Client side chỉ access được VITE_ env variables
754
781
  // Transform này chạy ở runtime khi module được load trong SSR context
755
- if (options?.ssr) {
782
+ if (transformOptions?.ssr) {
756
783
  let transformedCode = code;
757
784
  let hasChanges = false;
758
785
 
@@ -882,6 +909,9 @@ export async function loadMiddleware() {
882
909
  if (id === '\0' + VIRTUAL_L5E_ISLANDS) {
883
910
  return `export const islandModules = import.meta.glob('/src/**/react/*.{tsx,jsx}');`;
884
911
  }
912
+ if (id === '\0' + VIRTUAL_L5E_COMPACT_RENDERER) {
913
+ return `export * as reactDomClient from 'react-dom/client'; export { createElement } from 'react';`;
914
+ }
885
915
 
886
916
  // Virtual module: l5e-island-strategies
887
917
  if (id === '\0' + VIRTUAL_L5E_ISLAND_STRATEGIES) {
@@ -0,0 +1,158 @@
1
+ import type { IslandMeta } from './types';
2
+ import { strategies, registerMountStrategy } from './strategy-registry';
3
+
4
+ registerMountStrategy('load', (mount) => void mount());
5
+ registerMountStrategy('idle', (mount) => {
6
+ if ('requestIdleCallback' in window) requestIdleCallback(() => mount());
7
+ else setTimeout(() => mount(), 200);
8
+ });
9
+ registerMountStrategy('visible', (mount, opts, el) => {
10
+ const observer = new IntersectionObserver(
11
+ (entries) => {
12
+ if (entries.some((entry) => entry.isIntersecting)) {
13
+ observer.disconnect();
14
+ mount();
15
+ }
16
+ },
17
+ { rootMargin: opts || '200px' },
18
+ );
19
+ observer.observe(el);
20
+ return () => observer.disconnect();
21
+ });
22
+ registerMountStrategy('media', (mount, opts) => {
23
+ if (!opts) {
24
+ console.error('[l5e-island] Strategy "media" requires mountOpts (media query)');
25
+ return;
26
+ }
27
+ const query = window.matchMedia(opts);
28
+ if (query.matches) mount();
29
+ else {
30
+ const handler = (event: MediaQueryListEvent) => event.matches && mount();
31
+ query.addEventListener('change', handler, { once: true });
32
+ return () => query.removeEventListener('change', handler);
33
+ }
34
+ });
35
+ registerMountStrategy('none', () => {});
36
+
37
+ import 'virtual:l5e-island-strategies';
38
+
39
+ type CompactIslandModule = {
40
+ reactDomClient: typeof import('react-dom/client');
41
+ createElement: typeof import('react').createElement;
42
+ module: Record<string, any>;
43
+ };
44
+ type CompactIslandPlan = { module: string; renderer: string; js: string[]; css: string[] };
45
+ const registry: Record<string, CompactIslandPlan> = ((window as any).__L5E_ISLANDS__ ||= {});
46
+ let islandData: unknown[] | null = null;
47
+ const scheduled = new WeakSet<Element>();
48
+
49
+ function props(element: Element): Record<string, unknown> {
50
+ const index = element.getAttribute('data-island-idx');
51
+ if (index === null) return JSON.parse(element.getAttribute('data-island-props') || '{}');
52
+ if (!islandData) {
53
+ const store = document.getElementById('_l5e_data_');
54
+ try {
55
+ const parsed = JSON.parse(store?.textContent || '[]');
56
+ islandData = Array.isArray(parsed) ? parsed : [];
57
+ } catch (error) {
58
+ console.error('[l5e-island] Failed to parse _l5e_data_ props store:', error);
59
+ islandData = [];
60
+ }
61
+ }
62
+ return (islandData[Number(index)] as Record<string, unknown>) || {};
63
+ }
64
+
65
+ function discover(): IslandMeta[] {
66
+ return Array.from(document.querySelectorAll('[data-island]')).map((element) => ({
67
+ element: element as HTMLElement,
68
+ registryKey: element.getAttribute('data-island')!,
69
+ exportName: element.getAttribute('data-island-name')!,
70
+ props: props(element),
71
+ mount: element.getAttribute('data-island-mount') || 'load',
72
+ mountOpts: element.getAttribute('data-island-opts') || undefined,
73
+ ssr: element.hasAttribute('data-island-ssr'),
74
+ }));
75
+ }
76
+
77
+ function mount(island: IslandMeta) {
78
+ let mounted = false;
79
+ return async () => {
80
+ if (mounted) return;
81
+ mounted = true;
82
+ const plan = registry[island.registryKey];
83
+ if (!plan) {
84
+ console.error(`[l5e-island] Component "${island.registryKey}" not found in page registry.`);
85
+ return;
86
+ }
87
+ try {
88
+ // Schedule the complete known static closure before awaiting any response.
89
+ // Modulepreload fetches without evaluating; native import retains ESM order.
90
+ for (const href of plan.js) {
91
+ if (document.querySelector(`link[rel="modulepreload"][href="${CSS.escape(href)}"]`))
92
+ continue;
93
+ const link = document.createElement('link');
94
+ link.rel = 'modulepreload';
95
+ link.crossOrigin = 'anonymous';
96
+ link.href = href;
97
+ document.head.append(link);
98
+ }
99
+ await Promise.all(
100
+ plan.css.map(
101
+ (href) =>
102
+ new Promise<void>((resolve, reject) => {
103
+ const existing = document.querySelector<HTMLLinkElement>(
104
+ `link[rel="stylesheet"][href="${CSS.escape(href)}"]`,
105
+ );
106
+ if (existing?.sheet) return resolve();
107
+ const link = existing || document.createElement('link');
108
+ link.rel = 'stylesheet';
109
+ link.crossOrigin = 'anonymous';
110
+ link.href = href;
111
+ link.addEventListener('load', () => resolve(), { once: true });
112
+ link.addEventListener('error', () => reject(new Error(`Failed to load ${href}`)), {
113
+ once: true,
114
+ });
115
+ if (!existing) document.head.append(link);
116
+ }),
117
+ ),
118
+ );
119
+ const [renderer, module] = await Promise.all([
120
+ import(/* @vite-ignore */ plan.renderer),
121
+ import(/* @vite-ignore */ plan.module),
122
+ ]);
123
+ const { reactDomClient, createElement } = renderer as Omit<
124
+ CompactIslandModule,
125
+ 'module'
126
+ >;
127
+ const Component = module.default || module[island.exportName];
128
+ if (!Component) throw new Error(`No export "default" or "${island.exportName}" in module`);
129
+ if (island.ssr) {
130
+ reactDomClient.hydrateRoot(island.element, createElement(Component, island.props));
131
+ } else {
132
+ const root = reactDomClient.createRoot(island.element);
133
+ root.render(createElement(Component, island.props));
134
+ }
135
+ } catch (error) {
136
+ console.error(`[l5e-island] Failed to mount "${island.registryKey}":`, error);
137
+ }
138
+ };
139
+ }
140
+
141
+ function boot() {
142
+ for (const island of discover()) {
143
+ if (scheduled.has(island.element)) continue;
144
+ // A fragment swap can expose an element before its activation plan is installed.
145
+ if (!registry[island.registryKey]) continue;
146
+ const strategy = strategies.get(island.mount);
147
+ if (!strategy) {
148
+ console.error(`[l5e-island] Strategy "${island.mount}" not found.`);
149
+ continue;
150
+ }
151
+ scheduled.add(island.element);
152
+ strategy(mount(island), island.mountOpts, island.element);
153
+ }
154
+ }
155
+
156
+ (globalThis as any).__L5E_BOOT_ISLANDS__ = boot;
157
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
158
+ else boot();