evolit 0.1.8 → 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
@@ -20,8 +20,8 @@ It focuses on the core contract that matters first:
20
20
  - route modules live in `app/**/page.*`
21
21
  - layout modules live in `app/**/layout.*`
22
22
  - page and layout modules export a default async function
23
- - pages receive `{ params, searchParams, request }`
24
- - layouts receive `{ children, params, searchParams, request }`
23
+ - pages receive `{ params, searchParams, navigationContext, request }`
24
+ - layouts receive `{ children, params, searchParams, navigationContext, request }`
25
25
  - SSR document rendering is delegated to `@litsx/ssr`
26
26
 
27
27
  Supported authored module extensions:
@@ -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
 
@@ -266,20 +267,24 @@ Server pages and layouts can access the active Web Request context through `evol
266
267
  ```js
267
268
  import {
268
269
  cookies,
270
+ getRouteState,
269
271
  headers,
270
272
  notFound,
273
+ permanentRedirect,
271
274
  redirect,
272
275
  requestUrl,
273
276
  responseHeaders,
274
277
  } from "evolit/server";
275
278
 
276
279
  export default async function AccountPage() {
280
+ const { params, searchParams, navigationContext } = getRouteState();
281
+
277
282
  if (!cookies().has("session")) {
278
283
  redirect("/sign-in");
279
284
  }
280
285
 
281
286
  responseHeaders().set("x-account-page", "1");
282
- return `<p>${headers().get("user-agent")} ${requestUrl().pathname}</p>`;
287
+ return `<p>${headers().get("user-agent")} ${requestUrl().pathname} ${params.account ?? ""}</p>`;
283
288
  }
284
289
  ```
285
290
 
@@ -288,6 +293,14 @@ headers. `redirect()` and `permanentRedirect()` end rendering with `307` and `30
288
293
  `notFound()` renders a `404`. Reading headers, cookies, or the request URL makes the completed
289
294
  render dynamic, so it is not stored by the route response cache.
290
295
 
296
+ `getRouteState()` is the request-scoped server counterpart for reading the active route from nested
297
+ server components and helpers that do not receive route props directly. It returns the current
298
+ `{ url, params, searchParams, navigationContext }` as a read-only snapshot. It deliberately has no
299
+ `push`, `replace`, `refresh`, pending state, or history mutation methods: those belong to the browser
300
+ `useNavigation()` API. Redirects and `notFound()` remain separate server control-flow functions.
301
+ Reading `getRouteState().url` has the same dynamic-rendering semantics as `requestUrl()`; reading
302
+ `params` and `searchParams` participates in the normal segment-cache key tracking.
303
+
291
304
  ## Extensions
292
305
 
293
306
  Optional integrations are configured explicitly in `evolit.config.js`. Core only coordinates their
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "evolit",
3
- "version": "0.1.8",
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);
@@ -1,4 +1,3 @@
1
- export function getNavigation() { throw new Error("Navigation is only available in a browser context."); }
2
1
  export { createHref } from "./navigation-url.js";
3
2
  export function useNavigation() { throw new Error("useNavigation() is only available in a browser component."); }
4
3
  export function useParams() { throw new Error("useParams() is only available in a browser component."); }
package/src/render.js CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  getRequestContextResponse,
15
15
  isEvolitHttpSignal,
16
16
  runWithRequestContext,
17
+ runWithRouteState,
17
18
  } from "./request-context.js";
18
19
  import { createDevelopmentEventReporter } from "./development-events.js";
19
20
  import {
@@ -294,6 +295,67 @@ function createTrackedRouteValues(values) {
294
295
  };
295
296
  }
296
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
+
297
359
  function createSegmentCacheKey(
298
360
  segment,
299
361
  idPrefix,
@@ -302,30 +364,26 @@ function createSegmentCacheKey(
302
364
  searchParams,
303
365
  navigationContext,
304
366
  ) {
305
- const select = (values) => Object.fromEntries(
306
- (profile.all ? Object.keys(values) : profile.keys)
307
- .sort()
308
- .map((key) => [key, values[key]]),
309
- );
310
367
  return JSON.stringify({
311
368
  modulePath: segment.modulePath,
312
369
  idPrefix,
313
- params: select(params),
314
- searchParams: select(searchParams),
315
- navigationContext: navigationContext ?? null,
370
+ params: selectTrackedValues(profile.params, params),
371
+ searchParams: selectTrackedValues(profile.searchParams, searchParams),
372
+ navigationContext: selectTrackedNavigationContext(
373
+ profile.navigationContext,
374
+ navigationContext,
375
+ ),
316
376
  });
317
377
  }
318
378
 
319
379
  function createSegmentInputKey(profile, params, searchParams, navigationContext) {
320
- const select = (values) => Object.fromEntries(
321
- (profile.all ? Object.keys(values) : profile.keys)
322
- .sort()
323
- .map((key) => [key, values[key]]),
324
- );
325
380
  return JSON.stringify({
326
- params: select(params),
327
- searchParams: select(searchParams),
328
- navigationContext: navigationContext ?? null,
381
+ params: selectTrackedValues(profile.params, params),
382
+ searchParams: selectTrackedValues(profile.searchParams, searchParams),
383
+ navigationContext: selectTrackedNavigationContext(
384
+ profile.navigationContext,
385
+ navigationContext,
386
+ ),
329
387
  });
330
388
  }
331
389
 
@@ -456,18 +514,37 @@ async function renderSegmentedComponentTree(
456
514
  const idPrefix = `${segment.id}-p${projectionPath.join("-") || "0"}`;
457
515
 
458
516
  if (index === layoutComponents.length) {
459
- const pageValue = await component({
460
- params: requestContext.params,
461
- searchParams: requestContext.searchParams,
462
- navigationContext: requestContext.navigationContext,
463
- request,
464
- ...extraProps,
465
- }, 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));
466
533
  const pageResult = await renderToString(wrapRouteSegment(segment, pageValue), {
467
534
  assetResolver: options.assetResolver,
468
535
  context: { idPrefix },
469
536
  });
470
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
+ );
471
548
  results.push(pageResult);
472
549
  return pageResult.html;
473
550
  }
@@ -485,26 +562,36 @@ async function renderSegmentedComponentTree(
485
562
  if (!layoutResult) {
486
563
  const trackedParams = createTrackedRouteValues(requestContext.params);
487
564
  const trackedSearchParams = createTrackedRouteValues(requestContext.searchParams);
565
+ const trackedNavigationContext = createTrackedNavigationContext(
566
+ requestContext.navigationContext,
567
+ );
488
568
  const didUseDynamicRequestData = requestContext.didUseDynamicRequestData;
489
- const layoutValue = await layoutComponents[index]({
569
+ layoutResult = await runWithRouteState(requestContext, {
490
570
  params: trackedParams.value,
491
571
  searchParams: trackedSearchParams.value,
492
- navigationContext: requestContext.navigationContext,
493
- request,
494
- children: withForwardedChildRef(html`${unsafeHTML(childrenMarker)}`, childRef),
495
- }, incomingRef);
496
- layoutResult = await renderToString(wrapRouteSegment(segment, layoutValue), {
497
- assetResolver: options.assetResolver,
498
- context: { idPrefix },
572
+ navigationContext: trackedNavigationContext.value,
573
+ trackNavigationContext: () => trackedNavigationContext.read(),
574
+ }, async () => {
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);
584
+ return renderToString(wrapRouteSegment(segment, layoutValue), {
585
+ assetResolver: options.assetResolver,
586
+ context: { idPrefix },
587
+ });
499
588
  });
500
589
  layoutResult.segmentModulePath = segment.modulePath;
501
- profile = {
502
- all: trackedParams.profile().all || trackedSearchParams.profile().all,
503
- keys: [...new Set([
504
- ...trackedParams.profile().keys,
505
- ...trackedSearchParams.profile().keys,
506
- ])].sort(),
507
- };
590
+ profile = createSegmentDependencyProfile(
591
+ trackedParams,
592
+ trackedSearchParams,
593
+ trackedNavigationContext,
594
+ );
508
595
  if (!didUseDynamicRequestData && !requestContext.didUseDynamicRequestData) {
509
596
  options.segmentCache?.set(
510
597
  segment,
@@ -10,3 +10,4 @@ export function redirect() { return throwServerOnlyApi("redirect"); }
10
10
  export function requestUrl() { return throwServerOnlyApi("requestUrl"); }
11
11
  export function responseHeaders() { return throwServerOnlyApi("responseHeaders"); }
12
12
  export function getRequestContext() { return throwServerOnlyApi("getRequestContext"); }
13
+ export function getRouteState() { return throwServerOnlyApi("getRouteState"); }
@@ -2,9 +2,11 @@ import { AsyncLocalStorage } from "node:async_hooks";
2
2
  import { readNavigationContext } from "./navigation-context.js";
3
3
 
4
4
  const REQUEST_CONTEXT_STORAGE = Symbol.for("evolit.request-context.storage");
5
+ const ROUTE_STATE_STORAGE = Symbol.for("evolit.route-state.storage");
5
6
  const HTTP_SIGNAL_CLASS = Symbol.for("evolit.request-context.http-signal");
6
7
 
7
8
  const requestContextStorage = globalThis[REQUEST_CONTEXT_STORAGE] ??= new AsyncLocalStorage();
9
+ const routeStateStorage = globalThis[ROUTE_STATE_STORAGE] ??= new AsyncLocalStorage();
8
10
  const EvolitHttpSignal = globalThis[HTTP_SIGNAL_CLASS] ??= class EvolitHttpSignal extends Error {
9
11
  constructor(type, options = {}) {
10
12
  super(type);
@@ -111,11 +113,14 @@ export function createRequestContext({
111
113
  extensionValues = {},
112
114
  didUseDynamicRequestData = false,
113
115
  }) {
116
+ const routeNavigationContext = navigationContext == null
117
+ ? null
118
+ : Object.freeze({ ...navigationContext });
114
119
  const context = {
115
120
  request,
116
121
  params: Object.freeze({ ...params }),
117
122
  searchParams: Object.freeze({ ...searchParams }),
118
- navigationContext,
123
+ navigationContext: routeNavigationContext,
119
124
  responseHeaders: new Headers(),
120
125
  responseCookies: [],
121
126
  didUseDynamicRequestData: didUseDynamicRequestData === true,
@@ -130,6 +135,56 @@ export function runWithRequestContext(context, callback) {
130
135
  return requestContextStorage.run(context, callback);
131
136
  }
132
137
 
138
+ export function runWithRouteState(context, routeState, callback) {
139
+ return routeStateStorage.run({ context, ...routeState }, callback);
140
+ }
141
+
142
+ /**
143
+ * Returns a read-only snapshot of the active SSR route. Unlike the browser
144
+ * navigation controller, this state has no history or mutation methods.
145
+ * Reading `url` follows the same dynamic-rendering semantics as requestUrl().
146
+ *
147
+ * @returns {{
148
+ * url: URL,
149
+ * params: Readonly<Record<string, string | string[] | undefined>>,
150
+ * searchParams: Readonly<Record<string, string | string[] | undefined>>,
151
+ * navigationContext: Readonly<Record<string, unknown>> | null,
152
+ * }}
153
+ */
154
+ export function getRouteState() {
155
+ const context = getActiveContext();
156
+ const trackedState = routeStateStorage.getStore();
157
+ const routeState = trackedState?.context === context ? trackedState : context;
158
+ const state = {};
159
+
160
+ Object.defineProperties(state, {
161
+ url: {
162
+ enumerable: true,
163
+ get() {
164
+ context.didUseDynamicRequestData = true;
165
+ return new URL(context.request.url);
166
+ },
167
+ },
168
+ params: {
169
+ enumerable: true,
170
+ value: routeState.params,
171
+ },
172
+ searchParams: {
173
+ enumerable: true,
174
+ value: routeState.searchParams,
175
+ },
176
+ navigationContext: {
177
+ enumerable: true,
178
+ get() {
179
+ trackedState?.trackNavigationContext?.();
180
+ return routeState.navigationContext;
181
+ },
182
+ },
183
+ });
184
+
185
+ return Object.freeze(state);
186
+ }
187
+
133
188
  /**
134
189
  * Returns serializable values supplied by configured server extensions for
135
190
  * the active request. The object is isolated through AsyncLocalStorage and is
package/src/server-api.js CHANGED
@@ -52,3 +52,10 @@ export { responseHeaders } from "./request-context.js";
52
52
 
53
53
  /** Returns the active request's extension-provided serializable values. */
54
54
  export { getRequestContext } from "./request-context.js";
55
+
56
+ /**
57
+ * Returns a read-only snapshot of the active SSR route. This is the server
58
+ * counterpart for reading navigation state; redirects remain separate
59
+ * control-flow functions and browser history methods are intentionally absent.
60
+ */
61
+ export { getRouteState } from "./request-context.js";