evolit 0.1.0-alpha.26 → 0.1.0-alpha.28

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/README.md CHANGED
@@ -131,6 +131,18 @@ Both hooks update after client navigation. `useParams()` returns a read-only sna
131
131
  `URLSearchParams` from `useSearchParams()` is a local snapshot, so build a new href and navigate to
132
132
  it to update the URL.
133
133
 
134
+ ### Development refresh
135
+
136
+ In development, each browser subscribes its active URL over the Evolit WebSocket. After source
137
+ invalidation, the server renders the fresh SSR representation once per request context and pushes
138
+ the resulting delta directly to its subscribers. No follow-up browser request is needed. Server-only
139
+ module and static-asset changes update only the affected route segment while preserving the document,
140
+ scroll position, and persistent layouts.
141
+ Changes to a hydrated client boundary rebuild its browser artifact and update the stable Evolit
142
+ development proxy registered for that tag. Existing instances receive the new implementation
143
+ without redefining the Custom Element or replacing the document. Invalid deltas and failed hot
144
+ updates still fall back to a document reload.
145
+
134
146
  ### Progressive links and forms
135
147
 
136
148
  Links work without JavaScript. With JavaScript, Evolit intercepts ordinary same-origin left-clicks.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "evolit",
3
- "version": "0.1.0-alpha.26",
3
+ "version": "0.1.0-alpha.28",
4
4
  "description": "A convention-driven application framework for LitSX and web components.",
5
5
  "type": "module",
6
6
  "packageManager": "yarn@4.10.3",
@@ -29,7 +29,8 @@
29
29
  "./server": {
30
30
  "browser": "./src/request-context-browser.js",
31
31
  "default": "./src/server-api.js"
32
- }
32
+ },
33
+ "./internal/development-hot": "./src/development-hot-client.js"
33
34
  },
34
35
  "scripts": {
35
36
  "dev": "node ./src/cli.js dev",
@@ -54,9 +55,9 @@
54
55
  },
55
56
  "dependencies": {
56
57
  "@jridgewell/remapping": "^2.3.5",
57
- "@litsx/compiler": "0.10.0-canary-feat-ssr-20260731112627",
58
- "@litsx/core": "0.17.0-canary-feat-ssr-20260731112627",
59
- "@litsx/ssr": "0.2.0-canary-feat-ssr-20260731112627",
58
+ "@litsx/compiler": "0.10.0-canary-feat-ssr-20260731234459",
59
+ "@litsx/core": "0.17.0-canary-feat-ssr-20260731234459",
60
+ "@litsx/ssr": "0.2.0-canary-feat-ssr-20260731234459",
60
61
  "@litsx/typescript": "^0.9.0",
61
62
  "@rollup/plugin-commonjs": "^29.0.0",
62
63
  "@rollup/plugin-node-resolve": "^16.0.3",
@@ -27,6 +27,7 @@ const MODULE_SPECIFIER_PATTERN =
27
27
 
28
28
  const requireFromHere = createRequire(import.meta.url);
29
29
  const EVOLIT_NAVIGATION_BROWSER_ENTRY = fileURLToPath(new URL("./navigation-client.js", import.meta.url));
30
+ const EVOLIT_DEVELOPMENT_HOT_ENTRY = fileURLToPath(new URL("./development-hot-client.js", import.meta.url));
30
31
  const SHARED_VENDOR_SPECIFIERS = [
31
32
  "@litsx/core",
32
33
  "@litsx/core/elements",
@@ -34,6 +35,9 @@ const SHARED_VENDOR_SPECIFIERS = [
34
35
  "evolit/navigation",
35
36
  "lit",
36
37
  ];
38
+ const DEVELOPMENT_SHARED_VENDOR_SPECIFIERS = [
39
+ "evolit/internal/development-hot",
40
+ ];
37
41
  const browserSpecifierFilePathCache = new Map();
38
42
  const packageRootCache = new Map();
39
43
  const sharedRuntimeBuildCache = new Map();
@@ -303,6 +307,9 @@ export async function collectSharedVendorSpecifiers(
303
307
  ) {
304
308
  return [...new Set([
305
309
  ...(options.includeBase === false ? [] : SHARED_VENDOR_SPECIFIERS),
310
+ ...(options.includeBase === false || options.mode !== "development"
311
+ ? []
312
+ : DEVELOPMENT_SHARED_VENDOR_SPECIFIERS),
306
313
  ...additionalEntrySpecifiers.filter((specifier) => isBareSpecifier(specifier)),
307
314
  ])].sort();
308
315
  }
@@ -322,7 +329,9 @@ async function createSharedRuntimeInputEntries(projectRoot, entriesRoot, specifi
322
329
  }
323
330
  inputEntries[entryName] = specifier === "evolit/navigation"
324
331
  ? EVOLIT_NAVIGATION_BROWSER_ENTRY
325
- : await resolveBrowserSpecifierFilePath(specifier, { projectRoot });
332
+ : specifier === "evolit/internal/development-hot"
333
+ ? EVOLIT_DEVELOPMENT_HOT_ENTRY
334
+ : await resolveBrowserSpecifierFilePath(specifier, { projectRoot });
326
335
  }
327
336
 
328
337
  return inputEntries;
@@ -512,7 +521,10 @@ export async function buildSharedVendorRuntime(
512
521
  });
513
522
  const additionalRuntimes = await Promise.all(
514
523
  additionalEntrySpecifiers
515
- .filter((specifier) => !SHARED_VENDOR_SPECIFIERS.includes(specifier))
524
+ .filter((specifier) => ![
525
+ ...SHARED_VENDOR_SPECIFIERS,
526
+ ...DEVELOPMENT_SHARED_VENDOR_SPECIFIERS,
527
+ ].includes(specifier))
516
528
  .map((specifier) => buildSharedVendorRuntime(projectRoot, mode, {
517
529
  ...options,
518
530
  additionalEntrySpecifiers: [specifier],
@@ -548,6 +560,7 @@ export async function buildSharedVendorRuntime(
548
560
  );
549
561
  const specifiers = await collectSharedVendorSpecifiers(additionalEntrySpecifiers, {
550
562
  includeBase: options.includeBase !== false,
563
+ mode,
551
564
  });
552
565
  const inputEntries = await createSharedRuntimeInputEntries(projectRoot, entriesRoot, specifiers);
553
566
 
@@ -1161,7 +1174,8 @@ export function createHydrationBootstrap({
1161
1174
  : []).filter((moduleUrl) => typeof moduleUrl === "string" && moduleUrl.length > 0),
1162
1175
  )];
1163
1176
 
1164
- if (rootModuleUrls.length === 0 && clientImports.length === 0) {
1177
+ const hasHydrationWork = rootModuleUrls.length > 0 || clientImports.length > 0;
1178
+ if (!hasHydrationWork && !navigationModuleUrl) {
1165
1179
  return "";
1166
1180
  }
1167
1181
 
@@ -1169,9 +1183,8 @@ export function createHydrationBootstrap({
1169
1183
  .map((moduleUrl) => ` () => import(${JSON.stringify(moduleUrl)})`)
1170
1184
  .join(",\n");
1171
1185
 
1172
- const source = `
1186
+ const hydrationSource = hasHydrationWork ? `
1173
1187
  import { hydratePage, registerHydrationModules } from ${JSON.stringify(hydrationModuleUrl)};
1174
- ${navigationModuleUrl ? `import { getNavigation } from ${JSON.stringify(navigationModuleUrl)};` : ""}
1175
1188
 
1176
1189
  await hydratePage({
1177
1190
  clientImports: ${JSON.stringify(clientImports)},
@@ -1179,10 +1192,13 @@ await hydratePage({
1179
1192
  ${moduleLoaders}
1180
1193
  ])
1181
1194
  });
1182
- ${navigationModuleUrl ? "getNavigation();" : ""}
1183
- `;
1195
+ ` : "";
1196
+ const navigationSource = navigationModuleUrl ? `
1197
+ import { getNavigation } from ${JSON.stringify(navigationModuleUrl)};
1198
+ getNavigation();
1199
+ ` : "";
1184
1200
 
1185
- return escapeInlineScriptText(source);
1201
+ return escapeInlineScriptText(`${hydrationSource}${navigationSource}`);
1186
1202
  }
1187
1203
 
1188
1204
  export function normalizeHydrationDataForClient(
package/src/compiler.js CHANGED
@@ -802,6 +802,56 @@ function getDevelopmentGraphCacheKey(entryPath, options) {
802
802
  ].join("::");
803
803
  }
804
804
 
805
+ function wrapDevelopmentHydratableExports(code, moduleId, inputSourceMap = null) {
806
+ const sourcePath = moduleId;
807
+ const sourceFile = ts.createSourceFile(sourcePath, code, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS);
808
+ const magicSource = new MagicString(code);
809
+ const exports = [];
810
+ let index = 0;
811
+ for (const statement of sourceFile.statements) {
812
+ if (!ts.isClassDeclaration(statement) || !statement.name) continue;
813
+ const isHydratable = statement.members.some((member) =>
814
+ member.name?.getText(sourceFile).includes('Symbol.for("litsx.hydratableTag")')
815
+ );
816
+ if (!isHydratable) continue;
817
+ const modifiers = statement.modifiers ?? [];
818
+ const isExported = modifiers.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword);
819
+ if (!isExported) continue;
820
+ const isDefault = modifiers.some((modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword);
821
+ for (const modifier of modifiers) {
822
+ if (modifier.kind === ts.SyntaxKind.ExportKeyword || modifier.kind === ts.SyntaxKind.DefaultKeyword) {
823
+ magicSource.remove(modifier.getStart(sourceFile), modifier.end);
824
+ }
825
+ }
826
+ const className = statement.name.text;
827
+ const proxyName = `__evolit_hot_${className}_${index++}`;
828
+ exports.push(
829
+ `const ${proxyName} = __evolitHotComponent(${JSON.stringify(moduleId)}, ${className});`,
830
+ isDefault ? `export default ${proxyName};` : `export { ${proxyName} as ${className} };`,
831
+ );
832
+ }
833
+ if (exports.length === 0) return { code, map: inputSourceMap };
834
+ magicSource.append(
835
+ '\nimport { hotComponent as __evolitHotComponent } from "evolit/internal/development-hot";\n'
836
+ + `${exports.join("\n")}\n`,
837
+ );
838
+ const hotTransformSource = `${moduleId}#evolit-hot`;
839
+ const hotTransformMap = magicSource.generateMap({
840
+ hires: true,
841
+ includeContent: false,
842
+ source: hotTransformSource,
843
+ file: moduleId,
844
+ });
845
+ return {
846
+ code: magicSource.toString(),
847
+ map: inputSourceMap
848
+ ? remapping(hotTransformMap.toString(), (source) => (
849
+ source.endsWith("#evolit-hot") ? inputSourceMap : null
850
+ ))
851
+ : null,
852
+ };
853
+ }
854
+
805
855
  export function invalidateDevelopmentCompilationCache(projectRoot, changedPaths = null) {
806
856
  const resolvedProjectRoot = path.resolve(projectRoot);
807
857
  const prefix = `${resolvedProjectRoot}::`;
@@ -904,14 +954,24 @@ async function compileModuleGraphUncached(entryPath, options = {}) {
904
954
  clientImportParents,
905
955
  );
906
956
  }
907
- const targetCode = target === "client" && isMixedComponentModule
957
+ const projectedCode = target === "client" && isMixedComponentModule
908
958
  ? createMixedModuleClientProjection(transformed.code, sourcePath)
909
959
  : transformed.code;
960
+ const targetTransform = target === "client" && mode === "development"
961
+ ? wrapDevelopmentHydratableExports(
962
+ projectedCode,
963
+ path.relative(projectRoot, sourcePath).split(path.sep).join("/"),
964
+ projectedCode === transformed.code ? transformed.map ?? null : null,
965
+ )
966
+ : {
967
+ code: projectedCode,
968
+ map: projectedCode === transformed.code ? transformed.map ?? null : null,
969
+ };
910
970
  const rewritten = await rewriteRelativeSpecifiers({
911
971
  projectRoot,
912
972
  outputRoot,
913
973
  sourcePath,
914
- code: targetCode,
974
+ code: targetTransform.code,
915
975
  compileModule: (resolvedImportPath) => compileModule(resolvedImportPath, sourcePath),
916
976
  moduleMetadata,
917
977
  mode,
@@ -920,7 +980,7 @@ async function compileModuleGraphUncached(entryPath, options = {}) {
920
980
  serverImportQuery,
921
981
  sourceMaps,
922
982
  outputPath,
923
- inputSourceMap: targetCode === transformed.code ? transformed.map ?? null : null,
983
+ inputSourceMap: targetTransform.map,
924
984
  onDevelopmentEvent,
925
985
  staticAssetFiles,
926
986
  managedSourceRoots,
@@ -250,6 +250,7 @@ export async function createRequestRenderer({
250
250
  const devBundledEntries = new Set();
251
251
  const devPreparedClientModules = new Set();
252
252
  const devClientModuleDependencies = new Map();
253
+ const devKnownClientBoundarySources = new Set();
253
254
  const devInventoryByEntry = new Map();
254
255
  const devServerAssetImportsByEntry = {};
255
256
  const devClientBoundariesByEntry = {};
@@ -412,8 +413,9 @@ export async function createRequestRenderer({
412
413
  ].filter((moduleId) => typeof moduleId === "string" && moduleId.length > 0))];
413
414
  const unresolvedModules = [];
414
415
  for (const moduleId of renderedModules) {
415
- if (hasClientArtifact(moduleId)) continue;
416
416
  const sourcePath = await resolveRenderedClientSource(moduleId, routeResult);
417
+ if (sourcePath) devKnownClientBoundarySources.add(path.resolve(sourcePath));
418
+ if (hasClientArtifact(moduleId)) continue;
417
419
  const publicUrl = sourcePath ? currentAssetResolver(sourcePath) : null;
418
420
  if (typeof publicUrl === "string") {
419
421
  for (const root of result.hydrationData?.roots ?? []) {
@@ -453,6 +455,7 @@ export async function createRequestRenderer({
453
455
  );
454
456
  }
455
457
  await compileDevelopmentClientBoundary(sourcePath);
458
+ devKnownClientBoundarySources.add(path.resolve(sourcePath));
456
459
  compiledModules.push(getCompiledClientModule(projectRoot, sourcePath));
457
460
  }
458
461
 
@@ -588,12 +591,17 @@ export async function createRequestRenderer({
588
591
  /^(?:tsconfig|jsconfig)(?:\..+)?\.json$/.test(path.basename(changedPath))
589
592
  || path.basename(changedPath) === "package.json"
590
593
  );
594
+ const affectedRouteEntries = new Set();
591
595
  if (normalizedChangedPaths == null || moduleResolutionChanged) {
596
+ for (const entryPath of devInventoryByEntry.keys()) {
597
+ affectedRouteEntries.add(path.resolve(entryPath));
598
+ }
592
599
  devInventoryByEntry.clear();
593
600
  } else {
594
601
  for (const [entryPath, pendingInventory] of devInventoryByEntry) {
595
602
  const inventory = await pendingInventory;
596
603
  if (inventory.sourceFiles.some((sourcePath) => normalizedChangedPaths.has(path.resolve(sourcePath)))) {
604
+ affectedRouteEntries.add(path.resolve(entryPath));
597
605
  devInventoryByEntry.delete(entryPath);
598
606
  }
599
607
  }
@@ -607,6 +615,22 @@ export async function createRequestRenderer({
607
615
  )
608
616
  .map(([clientModule]) => clientModule),
609
617
  );
618
+ if (normalizedChangedPaths) {
619
+ for (const clientModule of new Set([
620
+ ...devPreparedClientModules,
621
+ ...devKnownClientBoundarySources,
622
+ ])) {
623
+ if (normalizedChangedPaths.has(path.resolve(clientModule))) {
624
+ affectedClientModules.add(clientModule);
625
+ }
626
+ }
627
+ }
628
+ const affectedExecutableClientEntryCount = [...affectedClientModules]
629
+ .filter((clientModule) =>
630
+ devPreparedClientModules.has(clientModule)
631
+ || devKnownClientBoundarySources.has(path.resolve(clientModule))
632
+ )
633
+ .length;
610
634
 
611
635
  if (affectedClientModules.size > 0 || normalizedChangedPaths == null) {
612
636
  developmentMetrics.selectivelyInvalidatedClientEntries += affectedClientModules.size;
@@ -637,6 +661,8 @@ export async function createRequestRenderer({
637
661
  type: "invalidated",
638
662
  changedPathCount: normalizedChangedPaths?.size ?? null,
639
663
  affectedClientEntryCount: affectedClientModules.size,
664
+ affectedExecutableClientEntryCount,
665
+ knownClientBoundaryCount: devKnownClientBoundarySources.size,
640
666
  });
641
667
 
642
668
  if (usesFrameworkRouteResolver) {
@@ -644,6 +670,13 @@ export async function createRequestRenderer({
644
670
  reportDevelopmentEvent({ type: "segment-cache-invalidated", entryCount: segmentEntryCount });
645
671
  effectiveRouteResolver = await createFrameworkRouteResolver();
646
672
  }
673
+
674
+ return {
675
+ affectedClientEntryCount: affectedExecutableClientEntryCount,
676
+ affectedRouteEntryPaths: [...affectedRouteEntries]
677
+ .map((entryPath) => path.relative(projectRoot, entryPath).split(path.sep).join("/"))
678
+ .sort(),
679
+ };
647
680
  });
648
681
  },
649
682
  async prepareRouteClientArtifacts(routeResult) {
@@ -663,6 +696,9 @@ export async function createRequestRenderer({
663
696
  sourceFiles: [...new Set([...inventoriesByEntry.values()].flatMap((entry) => entry.sourceFiles))].sort(),
664
697
  };
665
698
  const clientBoundaries = inventory.clientBoundaries;
699
+ for (const clientBoundary of clientBoundaries) {
700
+ devKnownClientBoundarySources.add(path.resolve(clientBoundary));
701
+ }
666
702
  const toProjectRelative = (filePath) => path.relative(projectRoot, filePath).split(path.sep).join("/");
667
703
  const serverAssetImports = {
668
704
  styles: inventory.styles.map((filePath) => getClientStaticAssetModule(projectRoot, filePath)),
@@ -836,11 +872,12 @@ export async function createDeploymentRuntime({
836
872
  return;
837
873
  }
838
874
 
839
- await renderer.invalidateDevelopmentState(changedPaths);
875
+ const invalidationResult = await renderer.invalidateDevelopmentState(changedPaths);
840
876
  if (typeof effectiveResponseCacheRuntime.store.clear === "function") {
841
877
  await effectiveResponseCacheRuntime.store.clear();
842
878
  }
843
879
  runtimeState.assetManifest = null;
880
+ return invalidationResult;
844
881
  },
845
882
  async handle(request) {
846
883
  const revalidationTasks = this.revalidationTasks ??= new Map();
@@ -51,6 +51,11 @@ export function createDevelopmentEventReporter() {
51
51
  `${terminal.cyan("[evolit]")} ${terminal.yellow("△")} Source change detected${event.changedPathCount == null ? "" : ` (${event.changedPathCount} files)`}; invalidated ${event.affectedClientEntryCount} client entries.`,
52
52
  );
53
53
  break;
54
+ case "live-update":
55
+ console.log(
56
+ `${terminal.cyan("[evolit]")} ${event.strategy === "delta" ? terminal.green("↻") : terminal.magenta("↻")} ${event.strategy === "delta" ? "Pushed incremental route refresh." : "Pushed hot client refresh."}`,
57
+ );
58
+ break;
54
59
  case "unmanaged-import":
55
60
  console.warn(
56
61
  `${terminal.yellow("[evolit]")} ${terminal.yellow("⚠")} Unmanaged import ${terminal.cyan(JSON.stringify(event.resolvedImportPath))} from ${terminal.cyan(JSON.stringify(event.sourcePath))} is outside the project root. Evolit does not watch it; changes may not trigger a rebuild, and it may be unavailable in another checkout or deployment. It will be emitted under ${terminal.cyan("/_evolit/static/__unmanaged__/")}.`,
@@ -0,0 +1,65 @@
1
+ import { adoptStyles } from "lit";
2
+
3
+ const HOT_COMPONENTS = Symbol.for("evolit.hotComponents");
4
+
5
+ function state() {
6
+ return globalThis[HOT_COMPONENTS] ??= new Map();
7
+ }
8
+
9
+ function refreshInstances(tagName, proxy) {
10
+ const roots = [globalThis.document].filter(Boolean);
11
+ while (roots.length > 0) {
12
+ const root = roots.shift();
13
+ for (const element of root.querySelectorAll?.(tagName) ?? []) {
14
+ if (!(element instanceof proxy)) continue;
15
+ if (
16
+ element.shadowRoot
17
+ && "adoptedStyleSheets" in element.shadowRoot
18
+ && Array.isArray(proxy.elementStyles)
19
+ ) {
20
+ adoptStyles(element.shadowRoot, proxy.elementStyles);
21
+ }
22
+ element.requestUpdate?.();
23
+ }
24
+ for (const element of root.querySelectorAll?.("*") ?? []) {
25
+ if (element.shadowRoot) roots.push(element.shadowRoot);
26
+ }
27
+ }
28
+ }
29
+
30
+ function replaceImplementation(record, implementation) {
31
+ const previousObservedAttributes = record.proxy.observedAttributes ?? [];
32
+ record.implementation = implementation;
33
+ Object.setPrototypeOf(record.proxy.prototype, implementation.prototype);
34
+ Object.setPrototypeOf(record.proxy, implementation);
35
+ delete record.proxy.finalized;
36
+ delete record.proxy.elementProperties;
37
+ delete record.proxy.elementStyles;
38
+ record.proxy.finalize?.();
39
+ const nextObservedAttributes = record.proxy.observedAttributes ?? [];
40
+ if (
41
+ previousObservedAttributes.length !== nextObservedAttributes.length
42
+ || previousObservedAttributes.some((attribute, index) => attribute !== nextObservedAttributes[index])
43
+ ) {
44
+ throw new Error(
45
+ `Cannot hot-update <${record.tagName}> because its observed attributes changed.`,
46
+ );
47
+ }
48
+ refreshInstances(record.tagName, record.proxy);
49
+ }
50
+
51
+ export function hotComponent(moduleId, implementation) {
52
+ const tagName = implementation?.[Symbol.for("litsx.hydratableTag")];
53
+ if (typeof tagName !== "string" || tagName.length === 0) return implementation;
54
+ const key = `${moduleId}::${tagName}`;
55
+ const existing = state().get(key);
56
+ if (existing) {
57
+ replaceImplementation(existing, implementation);
58
+ return existing.proxy;
59
+ }
60
+
61
+ class EvolitHotComponentProxy extends implementation {}
62
+ const record = { tagName, proxy: EvolitHotComponentProxy, implementation };
63
+ state().set(key, record);
64
+ return record.proxy;
65
+ }
@@ -10,6 +10,7 @@ export { createHref } from "./navigation-url.js";
10
10
 
11
11
  let browserNavigation = null;
12
12
  let nextHistoryEntryId = 0;
13
+ const DEVELOPMENT_REFRESH_EVENT = "evolit:development-refresh";
13
14
  const historySessionId = globalThis.crypto?.randomUUID?.()
14
15
  ?? `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
15
16
 
@@ -174,7 +175,16 @@ function syncDocumentState(nextState, documentRef) {
174
175
  if (script) script.textContent = JSON.stringify(nextState);
175
176
  }
176
177
 
177
- async function applyRouteDelta(delta, documentRef = document, signal) {
178
+ function versionClientImport(specifier, version) {
179
+ if (!version || typeof specifier !== "string") return specifier;
180
+ const url = new URL(specifier, globalThis.location?.href ?? "http://evolit.local/");
181
+ url.searchParams.set("__evolit_hot", String(version));
182
+ return url.origin === globalThis.location?.origin
183
+ ? `${url.pathname}${url.search}${url.hash}`
184
+ : url.href;
185
+ }
186
+
187
+ async function applyRouteDelta(delta, documentRef = document, signal, options = {}) {
178
188
  throwIfAborted(signal);
179
189
  const current = readRoute(documentRef);
180
190
  const nextSegments = delta.route?.segments ?? [];
@@ -185,18 +195,44 @@ async function applyRouteDelta(delta, documentRef = document, signal) {
185
195
  (segment, offset) => segment.inputKey !== currentSegments[offset]?.inputKey,
186
196
  );
187
197
  }
198
+ if (index < 0) {
199
+ index = nextSegments.findIndex(
200
+ (segment, offset) => segment.revision !== currentSegments[offset]?.revision,
201
+ );
202
+ }
203
+ if (index < 0 && options.requireSegmentChange === true) return true;
188
204
  if (index < 0) index = Math.max(0, nextSegments.length - 1);
189
- const next = nextSegments[index];
190
- const previous = currentSegments[index] ?? currentSegments.at(-1);
191
- if (!next || !previous) return;
192
- const targets = findMarkers(documentRef, previous.id);
193
- // A projection is positional: reusing projection zero for a second target
194
- // duplicates DOM (and hydration roots) when a layout changes its children
195
- // cardinality. A full document navigation is the only safe recovery when
196
- // the live tree and delta disagree.
197
- if (targets.length !== next.projections.length) return false;
205
+ let next;
206
+ let previous;
207
+ let targets;
208
+ // A leaf rendered as plain text can have no physical segment markers: Lit
209
+ // escapes those markers inside its parent template. Walk outward to the
210
+ // nearest replaceable ancestor rather than treating a zero/zero match as a
211
+ // successful update. This also recovers safely from stale child cardinality.
212
+ for (let candidate = index; candidate >= 0; candidate -= 1) {
213
+ const candidateNext = nextSegments[candidate];
214
+ const candidatePrevious = currentSegments[candidate] ?? currentSegments.at(-1);
215
+ if (!candidateNext || !candidatePrevious) continue;
216
+ const candidateTargets = findMarkers(documentRef, candidatePrevious.id);
217
+ if (
218
+ candidateTargets.length > 0
219
+ && candidateTargets.length === candidateNext.projections.length
220
+ ) {
221
+ next = candidateNext;
222
+ previous = candidatePrevious;
223
+ targets = candidateTargets;
224
+ break;
225
+ }
226
+ }
227
+ if (!next || !previous || !targets) return false;
198
228
  await syncRouteHeadAssets(delta.headAssets, documentRef);
199
229
  throwIfAborted(signal);
230
+ const clientImports = (delta.hydrationData?.clientImports ?? [])
231
+ .map((specifier) => versionClientImport(specifier, options.moduleVersion));
232
+ await registerHydrationModules(
233
+ await Promise.all(clientImports.map((specifier) => import(specifier))),
234
+ );
235
+ throwIfAborted(signal);
200
236
  syncRouteHeadMarkup(delta.head, documentRef);
201
237
  syncDocumentState(delta.document, documentRef);
202
238
  const insertedRoots = [];
@@ -232,16 +268,12 @@ async function applyRouteDelta(delta, documentRef = document, signal) {
232
268
  const script = documentRef.getElementById("__EVOLIT_ROUTE__");
233
269
  if (script) script.textContent = JSON.stringify(delta.route);
234
270
  if (delta.title) documentRef.title = delta.title;
235
- throwIfAborted(signal);
236
- await registerHydrationModules(
237
- await Promise.all((delta.hydrationData?.clientImports ?? []).map((specifier) => import(specifier))),
238
- );
239
271
  for (const { root, element } of insertedRoots) {
240
272
  throwIfAborted(signal);
241
273
  await hydrateRoot(element, {
242
274
  rootId: root.id,
243
275
  hydrationData: delta.hydrationData,
244
- clientImports: delta.hydrationData.clientImports,
276
+ clientImports,
245
277
  });
246
278
  }
247
279
  return true;
@@ -354,8 +386,19 @@ export function createBrowserNavigation(options = {}) {
354
386
  let navigationSequence = 0;
355
387
  let state = { status: "idle", url: windowRef.location.href, pendingUrl: null, error: null };
356
388
  const emit = () => listeners.forEach((listener) => listener(state));
389
+ const notifyLocationChange = () => {
390
+ const EventConstructor = windowRef.CustomEvent ?? globalThis.CustomEvent;
391
+ if (typeof windowRef.dispatchEvent === "function" && typeof EventConstructor === "function") {
392
+ windowRef.dispatchEvent(new EventConstructor("evolit:navigation", { detail: state }));
393
+ }
394
+ };
357
395
  const applyDelta = options.applyDelta
358
- ?? ((delta, { signal } = {}) => applyRouteDelta(delta, windowRef.document, signal));
396
+ ?? ((delta, context = {}) => applyRouteDelta(
397
+ delta,
398
+ windowRef.document,
399
+ context.signal,
400
+ context,
401
+ ));
359
402
 
360
403
  function navigateDocument(href, mode) {
361
404
  // This is deliberately a fallback, not the normal navigation path. It
@@ -482,7 +525,11 @@ export function createBrowserNavigation(options = {}) {
482
525
  })();
483
526
  if (!delta || !isCurrent()) return null;
484
527
  if (delta.type === "redirect") return navigate(delta.location, "replace", false);
485
- const applied = await applyDelta(delta, { signal: navigationController.signal });
528
+ const applied = await applyDelta(delta, {
529
+ signal: navigationController.signal,
530
+ moduleVersion: options.moduleVersion,
531
+ hot: options.hot,
532
+ });
486
533
  if (applied === false) {
487
534
  navigateDocument(href, mode);
488
535
  return null;
@@ -509,6 +556,7 @@ export function createBrowserNavigation(options = {}) {
509
556
  );
510
557
  state = { status: "idle", url: canonicalHref, pendingUrl: null, error: null };
511
558
  emit();
559
+ notifyLocationChange();
512
560
  return delta;
513
561
  } catch (error) {
514
562
  if (error?.name === "AbortError" || !isCurrent()) return null;
@@ -524,6 +572,37 @@ export function createBrowserNavigation(options = {}) {
524
572
  scrollPosition: event.state?.__evolitScroll ?? null,
525
573
  });
526
574
  });
575
+ windowRef.addEventListener(DEVELOPMENT_REFRESH_EVENT, (event) => {
576
+ const detail = event.detail;
577
+ if (!detail || typeof detail !== "object") return;
578
+ detail.handled = true;
579
+ if (detail.update?.type !== "update" || detail.update?.strategy === "reload") {
580
+ detail.result = Promise.resolve(false);
581
+ return;
582
+ }
583
+ controller?.abort();
584
+ const refreshController = new AbortController();
585
+ controller = refreshController;
586
+ state = { ...state, status: "pending", pendingUrl: windowRef.location.href, error: null };
587
+ emit();
588
+ detail.result = applyDelta(detail.update.delta, {
589
+ signal: refreshController.signal,
590
+ hot: detail.update.strategy === "hot",
591
+ moduleVersion: detail.update.version,
592
+ requireSegmentChange: true,
593
+ }).then((applied) => {
594
+ if (applied === false || refreshController.signal.aborted) return false;
595
+ state = { status: "idle", url: windowRef.location.href, pendingUrl: null, error: null };
596
+ emit();
597
+ return true;
598
+ }).catch((error) => {
599
+ if (error?.name !== "AbortError") {
600
+ state = { ...state, status: "error", pendingUrl: null, error };
601
+ emit();
602
+ }
603
+ throw error;
604
+ });
605
+ });
527
606
  windowRef.document.addEventListener("click", (event) => {
528
607
  if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
529
608
  const anchor = event.target?.closest?.("a[href]");
package/src/server.js CHANGED
@@ -65,7 +65,30 @@ function injectLiveReloadSnippet(response) {
65
65
  }
66
66
 
67
67
  function createLiveReloadSnippet() {
68
- return `<script data-evolit-live-reload>const evolitProtocol=window.location.protocol==="https:"?"wss:":"ws:";const evolitSocket=new WebSocket(evolitProtocol+"//"+window.location.host+${JSON.stringify(LIVE_RELOAD_PATHNAME)});evolitSocket.addEventListener("message",({data})=>{if(data==="reload")window.location.reload()});</script>`;
68
+ return `<script data-evolit-live-reload>(()=>{const protocol=location.protocol==="https:"?"wss:":"ws:";let socket;let connected=false;const subscribe=()=>{if(socket?.readyState===WebSocket.OPEN)socket.send(JSON.stringify({type:"subscribe",url:location.href}))};const connect=()=>{socket=new WebSocket(protocol+"//"+location.host+${JSON.stringify(LIVE_RELOAD_PATHNAME)});socket.addEventListener("open",()=>{if(connected){location.reload();return}connected=true;subscribe()});socket.addEventListener("message",({data})=>{let update;try{update=JSON.parse(data)}catch{location.reload();return}if(update?.type==="reload"){location.reload();return}if(update?.type!=="update"||update.url!==location.href){return}const detail={update,handled:false,result:null};dispatchEvent(new CustomEvent("evolit:development-refresh",{detail}));if(!detail.handled){location.reload();return}Promise.resolve(detail.result).then((applied)=>{if(!applied)location.reload()}).catch(()=>location.reload())});socket.addEventListener("close",()=>setTimeout(connect,250))};addEventListener("popstate",subscribe);addEventListener("evolit:navigation",subscribe);connect()})();</script>`;
69
+ }
70
+
71
+ function createDevelopmentNavigationRequest(subscription, origin) {
72
+ const headers = new Headers(subscription.headers);
73
+ headers.set("accept", "application/vnd.evolit.navigation+json");
74
+ for (const name of ["connection", "upgrade", "sec-websocket-key", "sec-websocket-version", "sec-websocket-extensions"]) {
75
+ headers.delete(name);
76
+ }
77
+ return new Request(new URL(subscription.url, origin), { headers });
78
+ }
79
+
80
+ function annotateDevelopmentSegmentRevisions(delta, revisions) {
81
+ if (delta?.type !== "route" || !Array.isArray(delta.route?.segments)) return delta;
82
+ return {
83
+ ...delta,
84
+ route: {
85
+ ...delta.route,
86
+ segments: delta.route.segments.map((segment) => {
87
+ const revision = revisions.get(segment.modulePath);
88
+ return revision == null ? segment : { ...segment, revision };
89
+ }),
90
+ },
91
+ };
69
92
  }
70
93
 
71
94
  async function createRecursiveDirectoryWatcher(rootDirectory, onChange, options = {}) {
@@ -194,9 +217,22 @@ async function createServer(projectRoot, mode, explicitPort, options = {}) {
194
217
  });
195
218
  const port = getPort(explicitPort);
196
219
  const liveReloadServer = mode === "development" ? new WebSocketServer({ noServer: true }) : null;
197
- const liveReloadSockets = new Set();
198
- liveReloadServer?.on("connection", (socket) => {
199
- liveReloadSockets.add(socket);
220
+ const liveReloadSockets = new Map();
221
+ liveReloadServer?.on("connection", (socket, request) => {
222
+ const subscription = { url: null, headers: request.headers };
223
+ liveReloadSockets.set(socket, subscription);
224
+ socket.on("message", (message) => {
225
+ try {
226
+ const payload = JSON.parse(String(message));
227
+ if (payload?.type !== "subscribe" || typeof payload.url !== "string") return;
228
+ const origin = `http://${request.headers.host ?? `localhost:${port}`}`;
229
+ const url = new URL(payload.url, origin);
230
+ if (url.origin !== new URL(origin).origin) return;
231
+ subscription.url = url.href;
232
+ } catch {
233
+ // Ignore malformed development protocol messages.
234
+ }
235
+ });
200
236
  socket.on("close", () => {
201
237
  liveReloadSockets.delete(socket);
202
238
  });
@@ -206,6 +242,63 @@ async function createServer(projectRoot, mode, explicitPort, options = {}) {
206
242
  let resolvePendingInvalidation = null;
207
243
  let invalidationPromise = Promise.resolve();
208
244
  const pendingChangedPaths = new Set();
245
+ let developmentVersion = 0;
246
+ const segmentRevisions = new Map();
247
+
248
+ async function pushDevelopmentUpdates(changedPaths, invalidationResult) {
249
+ developmentVersion += 1;
250
+ for (const modulePath of invalidationResult?.affectedRouteEntryPaths ?? []) {
251
+ segmentRevisions.set(modulePath, developmentVersion);
252
+ }
253
+ const strategy = (invalidationResult?.affectedClientEntryCount ?? 0) > 0 ? "hot" : "delta";
254
+ const renders = new Map();
255
+ await Promise.all([...liveReloadSockets].map(async ([socket, subscription]) => {
256
+ if (socket.readyState !== WebSocket.OPEN || !subscription.url) return;
257
+ const cacheKey = JSON.stringify([
258
+ subscription.url,
259
+ subscription.headers.cookie ?? "",
260
+ subscription.headers.authorization ?? "",
261
+ subscription.headers["accept-language"] ?? "",
262
+ ]);
263
+ let pending = renders.get(cacheKey);
264
+ if (!pending) {
265
+ pending = (async () => {
266
+ const origin = `http://${subscription.headers.host ?? `localhost:${port}`}`;
267
+ const response = await deploymentRuntime.handle(
268
+ createDevelopmentNavigationRequest(subscription, origin),
269
+ );
270
+ const navigationResponse = createNavigationResponseFromDocument(response);
271
+ if (!getResponseHeader(navigationResponse.headers, "content-type")
272
+ ?.includes("application/vnd.evolit.navigation+json")) return null;
273
+ const delta = JSON.parse(navigationResponse.body);
274
+ return annotateDevelopmentSegmentRevisions(delta, segmentRevisions);
275
+ })();
276
+ renders.set(cacheKey, pending);
277
+ }
278
+ try {
279
+ const delta = await pending;
280
+ if (!delta) {
281
+ socket.send(JSON.stringify({ type: "reload", version: developmentVersion }));
282
+ return;
283
+ }
284
+ socket.send(JSON.stringify({
285
+ type: "update",
286
+ strategy,
287
+ version: developmentVersion,
288
+ url: subscription.url,
289
+ delta,
290
+ }));
291
+ } catch {
292
+ socket.send(JSON.stringify({ type: "reload", version: developmentVersion }));
293
+ }
294
+ }));
295
+ reportDevelopmentEvent?.({
296
+ type: "live-update",
297
+ strategy,
298
+ affectedClientEntryCount: invalidationResult?.affectedClientEntryCount ?? null,
299
+ subscriberCount: liveReloadSockets.size,
300
+ });
301
+ }
209
302
 
210
303
  function scheduleDevelopmentInvalidation(changedPath = null) {
211
304
  if (changedPath) {
@@ -232,18 +325,12 @@ async function createServer(projectRoot, mode, explicitPort, options = {}) {
232
325
  resolvePendingInvalidation = null;
233
326
  invalidationPromise = invalidationPromise
234
327
  .catch(() => {})
235
- .then(() => deploymentRuntime.invalidateDevelopmentState(changedPaths));
236
- invalidationPromise.then(
237
- () => {
238
- for (const socket of liveReloadSockets) {
239
- if (socket.readyState === WebSocket.OPEN) {
240
- socket.send("reload");
241
- }
242
- }
243
- resolve();
244
- },
245
- resolve,
246
- );
328
+ .then(async () => {
329
+ const invalidationResult = await deploymentRuntime.invalidateDevelopmentState(changedPaths);
330
+ await pushDevelopmentUpdates(changedPaths, invalidationResult);
331
+ return invalidationResult;
332
+ });
333
+ invalidationPromise.then(resolve, resolve);
247
334
  }, 40);
248
335
  }
249
336
 
@@ -329,7 +416,7 @@ async function createServer(projectRoot, mode, explicitPort, options = {}) {
329
416
  clearTimeout(invalidationTimer);
330
417
  }
331
418
  for (const watcher of watchers) watcher.close();
332
- for (const socket of liveReloadSockets) {
419
+ for (const socket of liveReloadSockets.keys()) {
333
420
  socket.close();
334
421
  }
335
422
  if (liveReloadServer) {