evolit 0.2.0 → 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/README.md CHANGED
@@ -162,9 +162,10 @@ from a connected client component.
162
162
 
163
163
  Navigation context is intended for compact UI state that must survive back/forward navigation but
164
164
  does not belong in the public URL. It must be a JSON-safe object and is limited to 8 KiB after UTF-8
165
- serialization. Evolit includes it in browser, response, and segment cache identity so the same URL
166
- cannot reuse markup rendered for a different context. Invalid context is rejected at the client
167
- boundary; malformed context received by the server is ignored.
165
+ serialization. Evolit includes it in browser and response-cache identity. Segment caching and delta
166
+ identity track only the top-level context keys each layout or page reads, so unrelated context
167
+ changes preserve mounted layouts while consumers still update. Invalid context is rejected at the
168
+ client boundary; malformed context received by the server is ignored.
168
169
 
169
170
  Client components can also read the active route state with browser-only hooks:
170
171
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "evolit",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "A convention-driven application framework for LitSX and web components.",
5
5
  "type": "module",
6
6
  "packageManager": "yarn@4.10.3",
@@ -194,6 +194,79 @@ function syncDocumentState(nextState, documentRef) {
194
194
  if (script) script.textContent = JSON.stringify(nextState);
195
195
  }
196
196
 
197
+ function readElementAttributes(element) {
198
+ return Object.fromEntries([...element.attributes].map((attribute) => [
199
+ attribute.name,
200
+ attribute.value,
201
+ ]));
202
+ }
203
+
204
+ function isRootBodyProjection(segment, projection) {
205
+ return segment?.kind === "layout"
206
+ && segment?.depth === 0
207
+ && typeof projection?.html === "string"
208
+ && /<body(?:\s|>)/iu.test(projection.html);
209
+ }
210
+
211
+ function parseRootBodyProjection(projectionHtml, documentRef) {
212
+ const DOMParserConstructor = documentRef.defaultView?.DOMParser ?? globalThis.DOMParser;
213
+ if (typeof DOMParserConstructor !== "function") return null;
214
+ return new DOMParserConstructor().parseFromString(projectionHtml, "text/html").body;
215
+ }
216
+
217
+ function collectRouteRuntimeScripts(root) {
218
+ const scripts = [...root.querySelectorAll("script")];
219
+ return scripts.filter((script) =>
220
+ script.id === "__EVOLIT_ROUTE__"
221
+ || script.id === "__EVOLIT_DOCUMENT__"
222
+ || script.id === "__LITSX_HYDRATION__"
223
+ || script.hasAttribute("data-evolit-live-reload")
224
+ || (script.type === "module" && (
225
+ script.textContent?.includes("getNavigation")
226
+ || script.textContent?.includes("hydratePage")
227
+ )),
228
+ );
229
+ }
230
+
231
+ function replaceRootBodyProjection({
232
+ bodyProjection,
233
+ delta,
234
+ documentRef,
235
+ fragment,
236
+ target,
237
+ }) {
238
+ const body = documentRef.body;
239
+ if (!body || !bodyProjection) return false;
240
+
241
+ const authoredAttributes = readElementAttributes(bodyProjection);
242
+ const managedAttributes = normalizeManagedAttributes(delta.document?.bodyAttributes);
243
+ syncElementAttributes(
244
+ body,
245
+ readElementAttributes(body),
246
+ { ...authoredAttributes, ...managedAttributes },
247
+ );
248
+
249
+ const runtimeScripts = collectRouteRuntimeScripts(documentRef);
250
+ body.replaceChildren(target.start, fragment, target.end, ...runtimeScripts);
251
+
252
+ // HTML parsing can leave nodes from an authored root <body> beside the live
253
+ // body. Once the stable body owns the committed projection and runtime
254
+ // scripts, discard only those invalid element siblings.
255
+ for (const element of [...documentRef.documentElement.children]) {
256
+ if (element !== documentRef.head && element !== body) element.remove();
257
+ }
258
+ return true;
259
+ }
260
+
261
+ function syncRouteStateScripts(delta, documentRef) {
262
+ const routeScript = documentRef.getElementById("__EVOLIT_ROUTE__");
263
+ if (routeScript) routeScript.textContent = JSON.stringify(delta.route);
264
+ const hydrationScript = documentRef.getElementById("__LITSX_HYDRATION__");
265
+ if (hydrationScript && delta.hydrationData) {
266
+ hydrationScript.textContent = JSON.stringify(delta.hydrationData);
267
+ }
268
+ }
269
+
197
270
  function versionClientImport(specifier, version) {
198
271
  if (!version || typeof specifier !== "string") return specifier;
199
272
  const url = new URL(specifier, globalThis.location?.href ?? "http://evolit.local/");
@@ -260,9 +333,18 @@ async function applyRouteDelta(delta, documentRef = document, signal, options =
260
333
  throwIfAborted(signal);
261
334
  const projection = next.projections[offset] ?? next.projections[0];
262
335
  if (!projection) continue;
263
- const template = documentRef.createElement("template");
264
- template.innerHTML = projection.html;
265
- const fragment = template.content;
336
+ const bodyProjection = isRootBodyProjection(next, projection)
337
+ ? parseRootBodyProjection(projection.html, documentRef)
338
+ : null;
339
+ const fragment = documentRef.createDocumentFragment();
340
+ if (bodyProjection) {
341
+ for (const script of collectRouteRuntimeScripts(bodyProjection)) script.remove();
342
+ fragment.append(...bodyProjection.childNodes);
343
+ } else {
344
+ const template = documentRef.createElement("template");
345
+ template.innerHTML = projection.html;
346
+ fragment.append(template.content);
347
+ }
266
348
  materializeDeclarativeShadowDom(fragment);
267
349
  // Restore opaque SSR forwarded refs while the fragment is detached, before
268
350
  // its custom elements upgrade on insertion.
@@ -279,11 +361,19 @@ async function applyRouteDelta(delta, documentRef = document, signal, options =
279
361
  roots.map(({ root, element }) => ({ ...root, element })),
280
362
  delta.hydrationData,
281
363
  );
282
- const range = documentRef.createRange();
283
- range.setStartAfter(target.start);
284
- range.setEndBefore(target.end);
285
- range.deleteContents();
286
- range.insertNode(fragment);
364
+ if (!replaceRootBodyProjection({
365
+ bodyProjection,
366
+ delta,
367
+ documentRef,
368
+ fragment,
369
+ target,
370
+ })) {
371
+ const range = documentRef.createRange();
372
+ range.setStartAfter(target.start);
373
+ range.setEndBefore(target.end);
374
+ range.deleteContents();
375
+ range.insertNode(fragment);
376
+ }
287
377
  insertedRoots.push(...roots);
288
378
  target.start.data = `evolit:segment:start:${next.id}`;
289
379
  target.end.data = `evolit:segment:end:${next.id}`;
@@ -292,8 +382,7 @@ async function applyRouteDelta(delta, documentRef = document, signal, options =
292
382
  // document to point persistent layout refs at the new target, or clear them
293
383
  // when the replacement page no longer exposes one.
294
384
  prepareForwardedRefs(documentRef);
295
- const script = documentRef.getElementById("__EVOLIT_ROUTE__");
296
- if (script) script.textContent = JSON.stringify(delta.route);
385
+ syncRouteStateScripts(delta, documentRef);
297
386
  if (delta.title) documentRef.title = delta.title;
298
387
  for (const { root, element } of insertedRoots) {
299
388
  throwIfAborted(signal);
package/src/render.js CHANGED
@@ -295,6 +295,67 @@ function createTrackedRouteValues(values) {
295
295
  };
296
296
  }
297
297
 
298
+ function createTrackedNavigationContext(values) {
299
+ const tracked = createTrackedRouteValues(values ?? {});
300
+ let accessed = false;
301
+
302
+ return {
303
+ value: values == null ? null : tracked.value,
304
+ read() {
305
+ accessed = true;
306
+ return values == null ? null : tracked.value;
307
+ },
308
+ profile() {
309
+ return { accessed, ...tracked.profile() };
310
+ },
311
+ };
312
+ }
313
+
314
+ function createTrackedSegmentProps(
315
+ trackedParams,
316
+ trackedSearchParams,
317
+ trackedNavigationContext,
318
+ props,
319
+ ) {
320
+ const result = {
321
+ params: trackedParams.value,
322
+ searchParams: trackedSearchParams.value,
323
+ ...props,
324
+ };
325
+ Object.defineProperty(result, "navigationContext", {
326
+ enumerable: true,
327
+ get: () => trackedNavigationContext.read(),
328
+ });
329
+ return result;
330
+ }
331
+
332
+ function createSegmentDependencyProfile(
333
+ trackedParams,
334
+ trackedSearchParams,
335
+ trackedNavigationContext,
336
+ ) {
337
+ return {
338
+ params: trackedParams.profile(),
339
+ searchParams: trackedSearchParams.profile(),
340
+ navigationContext: trackedNavigationContext.profile(),
341
+ };
342
+ }
343
+
344
+ function selectTrackedValues(profile, values) {
345
+ const normalized = profile ?? { all: false, keys: [] };
346
+ return Object.fromEntries(
347
+ (normalized.all ? Object.keys(values ?? {}) : normalized.keys ?? [])
348
+ .sort()
349
+ .map((key) => [key, values?.[key]]),
350
+ );
351
+ }
352
+
353
+ function selectTrackedNavigationContext(profile, navigationContext) {
354
+ if (!profile?.accessed) return undefined;
355
+ if (navigationContext == null) return null;
356
+ return selectTrackedValues(profile, navigationContext);
357
+ }
358
+
298
359
  function createSegmentCacheKey(
299
360
  segment,
300
361
  idPrefix,
@@ -303,30 +364,26 @@ function createSegmentCacheKey(
303
364
  searchParams,
304
365
  navigationContext,
305
366
  ) {
306
- const select = (values) => Object.fromEntries(
307
- (profile.all ? Object.keys(values) : profile.keys)
308
- .sort()
309
- .map((key) => [key, values[key]]),
310
- );
311
367
  return JSON.stringify({
312
368
  modulePath: segment.modulePath,
313
369
  idPrefix,
314
- params: select(params),
315
- searchParams: select(searchParams),
316
- navigationContext: navigationContext ?? null,
370
+ params: selectTrackedValues(profile.params, params),
371
+ searchParams: selectTrackedValues(profile.searchParams, searchParams),
372
+ navigationContext: selectTrackedNavigationContext(
373
+ profile.navigationContext,
374
+ navigationContext,
375
+ ),
317
376
  });
318
377
  }
319
378
 
320
379
  function createSegmentInputKey(profile, params, searchParams, navigationContext) {
321
- const select = (values) => Object.fromEntries(
322
- (profile.all ? Object.keys(values) : profile.keys)
323
- .sort()
324
- .map((key) => [key, values[key]]),
325
- );
326
380
  return JSON.stringify({
327
- params: select(params),
328
- searchParams: select(searchParams),
329
- navigationContext: navigationContext ?? null,
381
+ params: selectTrackedValues(profile.params, params),
382
+ searchParams: selectTrackedValues(profile.searchParams, searchParams),
383
+ navigationContext: selectTrackedNavigationContext(
384
+ profile.navigationContext,
385
+ navigationContext,
386
+ ),
330
387
  });
331
388
  }
332
389
 
@@ -457,18 +514,37 @@ async function renderSegmentedComponentTree(
457
514
  const idPrefix = `${segment.id}-p${projectionPath.join("-") || "0"}`;
458
515
 
459
516
  if (index === layoutComponents.length) {
460
- const pageValue = await component({
461
- params: requestContext.params,
462
- searchParams: requestContext.searchParams,
463
- navigationContext: requestContext.navigationContext,
464
- request,
465
- ...extraProps,
466
- }, incomingRef);
517
+ const trackedParams = createTrackedRouteValues(requestContext.params);
518
+ const trackedSearchParams = createTrackedRouteValues(requestContext.searchParams);
519
+ const trackedNavigationContext = createTrackedNavigationContext(
520
+ requestContext.navigationContext,
521
+ );
522
+ const pageValue = await runWithRouteState(requestContext, {
523
+ params: trackedParams.value,
524
+ searchParams: trackedSearchParams.value,
525
+ navigationContext: trackedNavigationContext.value,
526
+ trackNavigationContext: () => trackedNavigationContext.read(),
527
+ }, () => component(createTrackedSegmentProps(
528
+ trackedParams,
529
+ trackedSearchParams,
530
+ trackedNavigationContext,
531
+ { request, ...extraProps },
532
+ ), incomingRef));
467
533
  const pageResult = await renderToString(wrapRouteSegment(segment, pageValue), {
468
534
  assetResolver: options.assetResolver,
469
535
  context: { idPrefix },
470
536
  });
471
537
  pageResult.segmentModulePath = segment.modulePath;
538
+ segment.inputKey = createSegmentInputKey(
539
+ createSegmentDependencyProfile(
540
+ trackedParams,
541
+ trackedSearchParams,
542
+ trackedNavigationContext,
543
+ ),
544
+ requestContext.params,
545
+ requestContext.searchParams,
546
+ requestContext.navigationContext,
547
+ );
472
548
  results.push(pageResult);
473
549
  return pageResult.html;
474
550
  }
@@ -486,31 +562,36 @@ async function renderSegmentedComponentTree(
486
562
  if (!layoutResult) {
487
563
  const trackedParams = createTrackedRouteValues(requestContext.params);
488
564
  const trackedSearchParams = createTrackedRouteValues(requestContext.searchParams);
565
+ const trackedNavigationContext = createTrackedNavigationContext(
566
+ requestContext.navigationContext,
567
+ );
489
568
  const didUseDynamicRequestData = requestContext.didUseDynamicRequestData;
490
569
  layoutResult = await runWithRouteState(requestContext, {
491
570
  params: trackedParams.value,
492
571
  searchParams: trackedSearchParams.value,
572
+ navigationContext: trackedNavigationContext.value,
573
+ trackNavigationContext: () => trackedNavigationContext.read(),
493
574
  }, async () => {
494
- const layoutValue = await layoutComponents[index]({
495
- params: trackedParams.value,
496
- searchParams: trackedSearchParams.value,
497
- navigationContext: requestContext.navigationContext,
498
- request,
499
- children: withForwardedChildRef(html`${unsafeHTML(childrenMarker)}`, childRef),
500
- }, incomingRef);
575
+ const layoutValue = await layoutComponents[index](createTrackedSegmentProps(
576
+ trackedParams,
577
+ trackedSearchParams,
578
+ trackedNavigationContext,
579
+ {
580
+ request,
581
+ children: withForwardedChildRef(html`${unsafeHTML(childrenMarker)}`, childRef),
582
+ },
583
+ ), incomingRef);
501
584
  return renderToString(wrapRouteSegment(segment, layoutValue), {
502
585
  assetResolver: options.assetResolver,
503
586
  context: { idPrefix },
504
587
  });
505
588
  });
506
589
  layoutResult.segmentModulePath = segment.modulePath;
507
- profile = {
508
- all: trackedParams.profile().all || trackedSearchParams.profile().all,
509
- keys: [...new Set([
510
- ...trackedParams.profile().keys,
511
- ...trackedSearchParams.profile().keys,
512
- ])].sort(),
513
- };
590
+ profile = createSegmentDependencyProfile(
591
+ trackedParams,
592
+ trackedSearchParams,
593
+ trackedNavigationContext,
594
+ );
514
595
  if (!didUseDynamicRequestData && !requestContext.didUseDynamicRequestData) {
515
596
  options.segmentCache?.set(
516
597
  segment,
@@ -175,7 +175,10 @@ export function getRouteState() {
175
175
  },
176
176
  navigationContext: {
177
177
  enumerable: true,
178
- value: context.navigationContext,
178
+ get() {
179
+ trackedState?.trackNavigationContext?.();
180
+ return routeState.navigationContext;
181
+ },
179
182
  },
180
183
  });
181
184