evolit 0.1.0-alpha.21 → 0.1.0-alpha.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "evolit",
3
- "version": "0.1.0-alpha.21",
3
+ "version": "0.1.0-alpha.22",
4
4
  "description": "A convention-driven application framework for LitSX and web components.",
5
5
  "type": "module",
6
6
  "packageManager": "yarn@4.10.3",
@@ -63,8 +63,17 @@
63
63
  "typescript": "^6.0.0",
64
64
  "ws": "^8.18.3"
65
65
  },
66
+ "peerDependencies": {
67
+ "@litsx/urql": "^0.3.0"
68
+ },
69
+ "peerDependenciesMeta": {
70
+ "@litsx/urql": {
71
+ "optional": true
72
+ }
73
+ },
66
74
  "devDependencies": {
67
75
  "@playwright/test": "^1.62.0",
76
+ "@webcomponents/scoped-custom-element-registry": "^0.0.10",
68
77
  "vite": "^8.1.5"
69
78
  }
70
79
  }
package/src/build.js CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  emitBundledClientAssets,
16
16
  emitHashedClientAssets,
17
17
  normalizeHydrationDataForClient,
18
+ resolveHydrationRootClientImports,
18
19
  resolveRouteClientImports,
19
20
  resolveSharedVendorModuleUrl,
20
21
  rewriteHydrationDataScript,
@@ -39,6 +40,7 @@ import {
39
40
  import { serializeRouteCachePolicy } from "./route-config.js";
40
41
  import { createSsrAdapter, renderRouteTreeWithAdapter } from "./ssr-adapter.js";
41
42
  import { ensureDirectory, writeJson } from "./fs-utils.js";
43
+ import { appendSsrUrqlData, runWithOptionalSsrUrqlScope } from "./urql-ssr.js";
42
44
 
43
45
  const CONTENT_TYPE_BY_EXTENSION = new Map([
44
46
  [".css", "text/css; charset=utf-8"],
@@ -288,7 +290,10 @@ export async function buildProject(projectRoot) {
288
290
  normalizeHydrationDataForClient(
289
291
  result.hydrationData,
290
292
  projectRoot,
291
- routeClientImports,
293
+ [
294
+ ...routeClientImports,
295
+ ...resolveHydrationRootClientImports(result.hydrationData, assetResolver),
296
+ ],
292
297
  ),
293
298
  );
294
299
  },
@@ -337,12 +342,23 @@ export async function buildProject(projectRoot) {
337
342
  seenPrerenderTargets.add(targetPathname);
338
343
 
339
344
  const targetRequest = new Request(`http://evolit.local${targetPathname}`);
340
- const routeResult = await routeResolver.resolveRequest(targetRequest);
341
- if (routeResult.type !== "route" || routeResult.cachePolicy.mode === "dynamic") {
345
+ const { routeResult, response } = await runWithOptionalSsrUrqlScope(async (urqlAdapter) => {
346
+ const resolvedRouteResult = await routeResolver.resolveRequest(targetRequest);
347
+ if (resolvedRouteResult.type !== "route" || resolvedRouteResult.cachePolicy.mode === "dynamic") {
348
+ return { routeResult: resolvedRouteResult, response: null };
349
+ }
350
+
351
+ const renderedResponse = await renderRouteTreeWithAdapter(resolvedRouteResult, ssrAdapter);
352
+ return {
353
+ routeResult: resolvedRouteResult,
354
+ response: urqlAdapter
355
+ ? appendSsrUrqlData(renderedResponse, await urqlAdapter.getUrqlSsrData())
356
+ : renderedResponse,
357
+ };
358
+ });
359
+ if (!response) {
342
360
  continue;
343
361
  }
344
-
345
- const response = await renderRouteTreeWithAdapter(routeResult, ssrAdapter);
346
362
  if (response.status !== 200) {
347
363
  continue;
348
364
  }
@@ -2032,6 +2032,25 @@ export function resolveRouteClientImports(routeResult, projectRoot, assetManifes
2032
2032
  }).filter((publicUrl) => typeof publicUrl === "string"))];
2033
2033
  }
2034
2034
 
2035
+ /**
2036
+ * Resolve the client modules which define the hydrated custom-element roots.
2037
+ *
2038
+ * Route entry modules are not required to re-export their LitSX components.
2039
+ * The document bootstrap has always loaded these root modules independently;
2040
+ * navigation deltas need the same information in their hydration payload.
2041
+ */
2042
+ export function resolveHydrationRootClientImports(hydrationData, assetResolver) {
2043
+ if (!hydrationData || typeof hydrationData !== "object" || typeof assetResolver !== "function") {
2044
+ return [];
2045
+ }
2046
+
2047
+ return [...new Set(
2048
+ (Array.isArray(hydrationData.roots) ? hydrationData.roots : [])
2049
+ .map((root) => assetResolver(root?.moduleId))
2050
+ .filter((publicUrl) => typeof publicUrl === "string" && publicUrl.length > 0),
2051
+ )];
2052
+ }
2053
+
2035
2054
  export function collectTransitiveStyleUrls(publicUrls, assetManifest) {
2036
2055
  const normalizedManifest = normalizeClientAssetManifest(assetManifest);
2037
2056
  if (!normalizedManifest) {
@@ -17,6 +17,7 @@ import {
17
17
  normalizeHydrationDataForClient,
18
18
  normalizeClientAssetManifest,
19
19
  resetDevelopmentAssetCaches,
20
+ resolveHydrationRootClientImports,
20
21
  resolveRouteClientImports,
21
22
  resolveSharedVendorModuleUrl,
22
23
  resolveBrowserPackageAssetFilePath,
@@ -33,6 +34,7 @@ import {
33
34
  } from "./response-cache.js";
34
35
  import { createSsrAdapter, renderRouteTreeWithAdapter } from "./ssr-adapter.js";
35
36
  import { createNavigationResponseFromDocument } from "./route-segments.js";
37
+ import { appendSsrUrqlData, runWithOptionalSsrUrqlScope } from "./urql-ssr.js";
36
38
  const CONTENT_TYPE_BY_EXTENSION = new Map([
37
39
  [".css", "text/css; charset=utf-8"],
38
40
  [".svg", "image/svg+xml"],
@@ -340,7 +342,13 @@ export async function createRequestRenderer({
340
342
  normalizeHydrationDataForClient(
341
343
  result.hydrationData,
342
344
  projectRoot,
343
- routeClientImports,
345
+ [
346
+ ...routeClientImports,
347
+ ...resolveHydrationRootClientImports(
348
+ result.hydrationData,
349
+ currentAssetResolver,
350
+ ),
351
+ ],
344
352
  ),
345
353
  );
346
354
  },
@@ -495,22 +503,27 @@ export async function createRequestRenderer({
495
503
  return effectiveRouteResolver.resolveRoutePolicy(request);
496
504
  },
497
505
  async renderRoute(request, routePolicyResult = null) {
498
- const shouldPrepareBeforeResolve = mode === "development" && !currentAssetManifest;
499
- let resolvedRoutePolicyResult = routePolicyResult;
500
- if (shouldPrepareBeforeResolve) {
501
- resolvedRoutePolicyResult ??= await effectiveRouteResolver.resolveRoutePolicy(request);
502
- await this.prepareRouteClientArtifacts(resolvedRoutePolicyResult);
503
- }
506
+ return runWithOptionalSsrUrqlScope(async (urqlAdapter) => {
507
+ const shouldPrepareBeforeResolve = mode === "development" && !currentAssetManifest;
508
+ let resolvedRoutePolicyResult = routePolicyResult;
509
+ if (shouldPrepareBeforeResolve) {
510
+ resolvedRoutePolicyResult ??= await effectiveRouteResolver.resolveRoutePolicy(request);
511
+ await this.prepareRouteClientArtifacts(resolvedRoutePolicyResult);
512
+ }
504
513
 
505
- const routeResult = await effectiveRouteResolver.resolveRequest(request, resolvedRoutePolicyResult);
506
- if (!shouldPrepareBeforeResolve || routeResult.boundaryModule) {
507
- await this.prepareRouteClientArtifacts(routeResult);
508
- }
509
- const response = await renderRouteTreeWithAdapter(routeResult, ssrAdapter);
510
- return {
511
- routeResult,
512
- response,
513
- };
514
+ const routeResult = await effectiveRouteResolver.resolveRequest(request, resolvedRoutePolicyResult);
515
+ if (!shouldPrepareBeforeResolve || routeResult.boundaryModule) {
516
+ await this.prepareRouteClientArtifacts(routeResult);
517
+ }
518
+ const renderedResponse = await renderRouteTreeWithAdapter(routeResult, ssrAdapter);
519
+ const response = urqlAdapter
520
+ ? appendSsrUrqlData(renderedResponse, await urqlAdapter.getUrqlSsrData())
521
+ : renderedResponse;
522
+ return {
523
+ routeResult,
524
+ response,
525
+ };
526
+ });
514
527
  },
515
528
  get assetManifest() {
516
529
  return currentAssetManifest;
@@ -1,5 +1,9 @@
1
1
  import { useHost, useOnConnect, useState } from "@litsx/core";
2
- import { hydrateRoot, registerHydrationModules } from "@litsx/ssr/hydration";
2
+ import {
3
+ applyHydrationPayload,
4
+ hydrateRoot,
5
+ registerHydrationModules,
6
+ } from "@litsx/ssr/hydration";
3
7
  import { createHref } from "./navigation-url.js";
4
8
 
5
9
  export { createHref } from "./navigation-url.js";
@@ -162,11 +166,21 @@ async function applyRouteDelta(delta, documentRef = document, signal) {
162
166
  const nextSegments = delta.route?.segments ?? [];
163
167
  const currentSegments = current?.segments ?? [];
164
168
  let index = nextSegments.findIndex((segment, offset) => segment.id !== currentSegments[offset]?.id);
169
+ if (index < 0) {
170
+ index = nextSegments.findIndex(
171
+ (segment, offset) => segment.inputKey !== currentSegments[offset]?.inputKey,
172
+ );
173
+ }
165
174
  if (index < 0) index = Math.max(0, nextSegments.length - 1);
166
175
  const next = nextSegments[index];
167
176
  const previous = currentSegments[index] ?? currentSegments.at(-1);
168
177
  if (!next || !previous) return;
169
178
  const targets = findMarkers(documentRef, previous.id);
179
+ // A projection is positional: reusing projection zero for a second target
180
+ // duplicates DOM (and hydration roots) when a layout changes its children
181
+ // cardinality. A full document navigation is the only safe recovery when
182
+ // the live tree and delta disagree.
183
+ if (targets.length !== next.projections.length) return false;
170
184
  await syncRouteHeadAssets(delta.headAssets, documentRef);
171
185
  throwIfAborted(signal);
172
186
  syncRouteHeadMarkup(delta.head, documentRef);
@@ -184,6 +198,14 @@ async function applyRouteDelta(delta, documentRef = document, signal) {
184
198
  root,
185
199
  element: findHydrationElement(fragment, root.id),
186
200
  })).filter((entry) => entry.element);
201
+ // A custom element already defined by an earlier route is upgraded as
202
+ // soon as this fragment is connected. Apply its SSR data while it is
203
+ // still detached, otherwise Lit starts an update against its declarative
204
+ // shadow root without the properties used to render it on the server.
205
+ applyHydrationPayload(
206
+ roots.map(({ root, element }) => ({ ...root, element })),
207
+ delta.hydrationData,
208
+ );
187
209
  const range = documentRef.createRange();
188
210
  range.setStartAfter(target.start);
189
211
  range.setEndBefore(target.end);
@@ -201,13 +223,14 @@ async function applyRouteDelta(delta, documentRef = document, signal) {
201
223
  await Promise.all((delta.hydrationData?.clientImports ?? []).map((specifier) => import(specifier))),
202
224
  );
203
225
  for (const { root, element } of insertedRoots) {
204
- throwIfAborted(signal);
205
- await hydrateRoot(element, {
206
- rootId: root.id,
207
- hydrationData: delta.hydrationData,
208
- clientImports: delta.hydrationData.clientImports,
209
- });
226
+ throwIfAborted(signal);
227
+ await hydrateRoot(element, {
228
+ rootId: root.id,
229
+ hydrationData: delta.hydrationData,
230
+ clientImports: delta.hydrationData.clientImports,
231
+ });
210
232
  }
233
+ return true;
211
234
  }
212
235
 
213
236
  function toHref(target, location) {
@@ -440,7 +463,11 @@ export function createBrowserNavigation(options = {}) {
440
463
  })();
441
464
  if (!delta || !isCurrent()) return null;
442
465
  if (delta.type === "redirect") return navigate(delta.location, "replace", false);
443
- await applyDelta(delta, { signal: navigationController.signal });
466
+ const applied = await applyDelta(delta, { signal: navigationController.signal });
467
+ if (applied === false) {
468
+ navigateDocument(href, mode);
469
+ return null;
470
+ }
444
471
  if (!isCurrent()) return null;
445
472
  const canonicalHref = toHref(delta.url ?? href, windowRef.location);
446
473
  if (!fromPopState) {
package/src/render.js CHANGED
@@ -263,6 +263,18 @@ function createSegmentCacheKey(segment, idPrefix, profile, params, searchParams)
263
263
  });
264
264
  }
265
265
 
266
+ function createSegmentInputKey(profile, params, searchParams) {
267
+ const select = (values) => Object.fromEntries(
268
+ (profile.all ? Object.keys(values) : profile.keys)
269
+ .sort()
270
+ .map((key) => [key, values[key]]),
271
+ );
272
+ return JSON.stringify({
273
+ params: select(params),
274
+ searchParams: select(searchParams),
275
+ });
276
+ }
277
+
266
278
  function getSegmentCacheExpiry(cachePolicy) {
267
279
  if (cachePolicy?.mode === "static") return Number.POSITIVE_INFINITY;
268
280
  if (cachePolicy?.mode === "revalidate" && Number.isFinite(cachePolicy.ttlSeconds)) {
@@ -296,6 +308,9 @@ function createSegmentRenderCache(projectRoot, onDevelopmentEvent) {
296
308
  onDevelopmentEvent?.({ type: "segment-cache-hit", modulePath: segment.modulePath });
297
309
  return entry.result;
298
310
  },
311
+ getProfile(segment) {
312
+ return profiles.get(segment.modulePath) ?? null;
313
+ },
299
314
  set(segment, idPrefix, params, searchParams, profile, result, cachePolicy) {
300
315
  const expiresAt = getSegmentCacheExpiry(cachePolicy);
301
316
  if (expiresAt == null) return;
@@ -379,6 +394,7 @@ async function renderSegmentedComponentTree(
379
394
  }
380
395
 
381
396
  const childrenMarker = createSegmentChildrenMarker(segment);
397
+ let profile = options.segmentCache?.getProfile(segment) ?? null;
382
398
  let layoutResult = options.segmentCache?.get(
383
399
  segment,
384
400
  idPrefix,
@@ -399,24 +415,32 @@ async function renderSegmentedComponentTree(
399
415
  assetResolver: options.assetResolver,
400
416
  context: { idPrefix },
401
417
  });
418
+ profile = {
419
+ all: trackedParams.profile().all || trackedSearchParams.profile().all,
420
+ keys: [...new Set([
421
+ ...trackedParams.profile().keys,
422
+ ...trackedSearchParams.profile().keys,
423
+ ])].sort(),
424
+ };
402
425
  if (!didUseDynamicRequestData && !requestContext.didUseDynamicRequestData) {
403
426
  options.segmentCache?.set(
404
427
  segment,
405
428
  idPrefix,
406
429
  requestContext.params,
407
430
  requestContext.searchParams,
408
- {
409
- all: trackedParams.profile().all || trackedSearchParams.profile().all,
410
- keys: [...new Set([
411
- ...trackedParams.profile().keys,
412
- ...trackedSearchParams.profile().keys,
413
- ])].sort(),
414
- },
431
+ profile,
415
432
  layoutResult,
416
433
  options.cachePolicy,
417
434
  );
418
435
  }
419
436
  }
437
+ if (profile) {
438
+ segment.inputKey = createSegmentInputKey(
439
+ profile,
440
+ requestContext.params,
441
+ requestContext.searchParams,
442
+ );
443
+ }
420
444
  results.push(layoutResult);
421
445
  const projectionCount = layoutResult.html.split(childrenMarker).length - 1;
422
446
  if (projectionCount === 0) return null;
@@ -81,11 +81,12 @@ export function createRouteSegmentPayload(routeResult) {
81
81
  : {}),
82
82
  }
83
83
  : null,
84
- segments: segments.map(({ id, kind, depth, modulePath }) => ({
84
+ segments: segments.map(({ id, kind, depth, modulePath, inputKey }) => ({
85
85
  id,
86
86
  kind,
87
87
  depth,
88
88
  modulePath,
89
+ ...(typeof inputKey === "string" ? { inputKey } : {}),
89
90
  })),
90
91
  };
91
92
  }
@@ -0,0 +1,60 @@
1
+ let adapterPromise = null;
2
+
3
+ function isMissingUrqlAdapter(error) {
4
+ return error?.code === "ERR_MODULE_NOT_FOUND"
5
+ && String(error.message).includes("@litsx/urql");
6
+ }
7
+
8
+ async function loadSsrUrqlAdapter() {
9
+ try {
10
+ return await import("@litsx/urql");
11
+ } catch (error) {
12
+ if (isMissingUrqlAdapter(error)) {
13
+ return null;
14
+ }
15
+ throw error;
16
+ }
17
+ }
18
+
19
+ export async function getSsrUrqlAdapter() {
20
+ adapterPromise ??= loadSsrUrqlAdapter();
21
+ return adapterPromise;
22
+ }
23
+
24
+ /**
25
+ * Opens the optional @litsx/urql request scope around one complete SSR render.
26
+ * Evolit owns only lifecycle here; client creation and URQL configuration stay
27
+ * entirely in the application and @litsx/urql.
28
+ */
29
+ export async function runWithOptionalSsrUrqlScope(callback, options = {}) {
30
+ const adapter = Object.hasOwn(options, "adapter")
31
+ ? options.adapter
32
+ : await getSsrUrqlAdapter();
33
+ if (!adapter) {
34
+ return callback(null);
35
+ }
36
+
37
+ return adapter.runWithUrqlScope(() => callback(adapter));
38
+ }
39
+
40
+ function escapeJsonForHtml(value) {
41
+ return JSON.stringify(value)
42
+ .replaceAll("<", "\\u003C")
43
+ .replaceAll(">", "\\u003E")
44
+ .replaceAll("&", "\\u0026");
45
+ }
46
+
47
+ /** Appends optional, application-defined URQL SSR data to an HTML response. */
48
+ export function appendSsrUrqlData(response, data) {
49
+ if (data === undefined || typeof response?.body !== "string") {
50
+ return response;
51
+ }
52
+
53
+ const script = `<script type="application/json" id="__LITSX_URQL_DATA__">${escapeJsonForHtml(data)}</script>`;
54
+ return {
55
+ ...response,
56
+ body: response.body.includes("</body>")
57
+ ? response.body.replace("</body>", `${script}\n</body>`)
58
+ : `${response.body}${script}`,
59
+ };
60
+ }