@c9up/aurora 0.1.13 → 0.1.15

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/src/hydrate.ts CHANGED
@@ -174,8 +174,11 @@ function hydrateReactiveStructured(
174
174
  if (firstRun) {
175
175
  firstRun = false;
176
176
  // Reuse SSR markup: hydrate reactive bindings INSIDE the nested
177
- // template against the captured nodes. Inner boundary markers
178
- // are consumed from the same cursor (document order).
177
+ // value against the captured nodes. Inner boundary markers are
178
+ // consumed from the same cursor (document order) — for an ARRAY this
179
+ // MUST recurse into every item, else the items' marker pairs go
180
+ // unconsumed and the cursor desyncs, wiring slots AFTER the list to
181
+ // the wrong range (SSR list "present but not painted").
179
182
  if (isTemplateResult(next)) {
180
183
  hydrateTemplateResult(
181
184
  next,
@@ -184,6 +187,14 @@ function hydrateReactiveStructured(
184
187
  mountHooks,
185
188
  markerCursor,
186
189
  );
190
+ } else if (Array.isArray(next)) {
191
+ hydrateArrayItems(
192
+ next,
193
+ currentNodes,
194
+ localCleanups,
195
+ mountHooks,
196
+ markerCursor,
197
+ );
187
198
  }
188
199
  return;
189
200
  }
@@ -213,6 +224,67 @@ function hydrateReactiveStructured(
213
224
  });
214
225
  }
215
226
 
227
+ /**
228
+ * Top-level live (SSR) node count a value contributes inside an array slot: a
229
+ * TemplateResult contributes its template's root-node count, a nested array the
230
+ * sum of its items, a non-empty scalar one text node, null/undefined/false none.
231
+ * Used to slice the array's range per item during hydration.
232
+ */
233
+ function liveNodeCount(value: unknown): number {
234
+ if (value === null || value === undefined || value === false) return 0;
235
+ if (isTemplateResult(value)) {
236
+ return getTemplate(value.strings).element.content.childNodes.length;
237
+ }
238
+ if (Array.isArray(value)) {
239
+ let n = 0;
240
+ for (const v of value) n += liveNodeCount(v);
241
+ return n;
242
+ }
243
+ return 1; // scalar → one inlined text node
244
+ }
245
+
246
+ /**
247
+ * Hydrate the items of a reactive array against the SSR nodes inside its marker
248
+ * range. Each item is hydrated against its own slice of the (marker-collapsed)
249
+ * range, IN ORDER, so every item's inner marker pairs are consumed in document
250
+ * order and the global cursor stays aligned for slots AFTER the list. Item
251
+ * templates need a stable top-level node count (the common
252
+ * `arr.map(x => html`<li>…</li>`)` shape — single root, no surrounding
253
+ * whitespace); bare adjacent scalar items can merge in the browser, so use
254
+ * template items for hydrated lists.
255
+ */
256
+ function hydrateArrayItems(
257
+ items: unknown[],
258
+ rangeNodes: ChildNode[],
259
+ cleanups: Disposer[],
260
+ mountHooks: Array<EffectCallback>,
261
+ markerCursor: MarkerCursor,
262
+ ): void {
263
+ const nodes = collapseMarkerRanges(rangeNodes);
264
+ let offset = 0;
265
+ for (const item of items) {
266
+ const count = liveNodeCount(item);
267
+ if (isTemplateResult(item)) {
268
+ hydrateTemplateResult(
269
+ item,
270
+ nodes.slice(offset, offset + count),
271
+ cleanups,
272
+ mountHooks,
273
+ markerCursor,
274
+ );
275
+ } else if (Array.isArray(item)) {
276
+ hydrateArrayItems(
277
+ item,
278
+ nodes.slice(offset, offset + count),
279
+ cleanups,
280
+ mountHooks,
281
+ markerCursor,
282
+ );
283
+ }
284
+ offset += count;
285
+ }
286
+ }
287
+
216
288
  /**
217
289
  * Adopt SSR markup inside `container`. `factory` is the same function
218
290
  * that was rendered server-side — its output (a TemplateResult tree)
@@ -376,6 +448,19 @@ function hydrateSlot(
376
448
  mountHooks: Array<EffectCallback>,
377
449
  markerCursor: MarkerCursor,
378
450
  ): void {
451
+ // Type guard: an attr/bool/prop/event slot needs an Element. On a SSR↔client
452
+ // structural desync the path can resolve to an EXISTING node of the wrong
453
+ // type (Text/Comment); casting it to Element and calling setAttribute would
454
+ // throw "setAttribute is not a function". Skip the binding (fail-soft) rather
455
+ // than crash hydration. Text slots accept text/comment/element, so they pass.
456
+ if (slot.kind !== "text" && node.nodeType !== 1) {
457
+ if (typeof console !== "undefined") {
458
+ console.warn(
459
+ `[aurora] hydrate: slot (${slot.kind}) path ${slot.path.join(".")} resolved to a non-element node — skipping binding`,
460
+ );
461
+ }
462
+ return;
463
+ }
379
464
  switch (slot.kind) {
380
465
  case "text":
381
466
  hydrateTextSlot(node, value, cleanups, mountHooks, markerCursor);
@@ -405,6 +490,24 @@ function hydrateSlot(
405
490
  * existing text node in place. For nested TemplateResults, we
406
491
  * recursively hydrate against the captured sibling range.
407
492
  */
493
+ /**
494
+ * First text node inside a marker pair's range, or a fresh empty one inserted
495
+ * before the end marker (when the SSR value was empty → no text node yet).
496
+ */
497
+ function reactiveTextNode(pair: MarkerPair): Text {
498
+ for (
499
+ let n = pair.start.nextSibling;
500
+ n !== null && n !== pair.end;
501
+ n = n.nextSibling
502
+ ) {
503
+ if (n.nodeType === 3 /* TEXT */) return n as Text;
504
+ }
505
+ const doc = pair.end.ownerDocument ?? document;
506
+ const fresh = doc.createTextNode("");
507
+ pair.end.parentNode?.insertBefore(fresh, pair.end);
508
+ return fresh;
509
+ }
510
+
408
511
  function hydrateTextSlot(
409
512
  commentMarker: Node,
410
513
  value: unknown,
@@ -412,14 +515,85 @@ function hydrateTextSlot(
412
515
  mountHooks: Array<EffectCallback>,
413
516
  markerCursor: MarkerCursor,
414
517
  ): void {
415
- // The path lands on the comment marker that EXISTS in the parsed
416
- // template but not in SSR output. Hydration walks the live siblings
417
- // to find the text node that holds the SSR value.
418
- // Strategy: the comment was located at child index N inside its
419
- // parent; SSR wrote the value as the immediately-preceding text
420
- // node (or nothing for null/false). Live node here is whatever the
421
- // path resolution returned — often a text node, sometimes an
422
- // element (for nested templates). We rebind in-place.
518
+ // Every text slot is SSR-wrapped in a <!--$-->…<!--/$--> pair, and its path
519
+ // resolves (via collapseMarkerRanges) to the start marker. Consume the
520
+ // matching pair in document order and bind within its range.
521
+ const pair = markerCursor.pairs[markerCursor.i];
522
+ if (pair === undefined) {
523
+ // Legacy markup without per-slot markers (mismatched older SSR build).
524
+ legacyHydrateTextSlot(
525
+ commentMarker,
526
+ value,
527
+ cleanups,
528
+ mountHooks,
529
+ markerCursor,
530
+ );
531
+ return;
532
+ }
533
+ markerCursor.i += 1;
534
+
535
+ const reactiveFn =
536
+ isSignal(value) || typeof value === "function"
537
+ ? (value as () => unknown)
538
+ : null;
539
+ const current = reactiveFn ? reactiveFn() : value;
540
+
541
+ // Structured value (nested template / array): reactive → swap on change;
542
+ // direct → adopt the SSR range once (inner bindings wired against it).
543
+ if (isTemplateResult(current) || Array.isArray(current)) {
544
+ if (reactiveFn) {
545
+ hydrateReactiveStructured(
546
+ reactiveFn,
547
+ pair,
548
+ cleanups,
549
+ mountHooks,
550
+ markerCursor,
551
+ );
552
+ return;
553
+ }
554
+ const range: ChildNode[] = [];
555
+ for (
556
+ let n = pair.start.nextSibling;
557
+ n !== null && n !== pair.end;
558
+ n = n.nextSibling
559
+ ) {
560
+ range.push(n as ChildNode);
561
+ }
562
+ if (isTemplateResult(current)) {
563
+ hydrateTemplateResult(current, range, cleanups, mountHooks, markerCursor);
564
+ } else if (Array.isArray(current)) {
565
+ // Direct (non-reactive) array — hydrate each item so its inner marker
566
+ // pairs are consumed and the cursor stays aligned (same as a reactive
567
+ // array's first run).
568
+ hydrateArrayItems(current, range, cleanups, mountHooks, markerCursor);
569
+ }
570
+ return;
571
+ }
572
+
573
+ // Scalar: a reactive scalar updates the range's text node on change; a static
574
+ // scalar is already rendered between the markers (nothing to wire).
575
+ if (reactiveFn) {
576
+ const textNode = reactiveTextNode(pair);
577
+ const dispose = effect(() => {
578
+ const v = reactiveFn();
579
+ textNode.data = v == null || v === false ? "" : String(v);
580
+ });
581
+ cleanups.push(dispose);
582
+ }
583
+ }
584
+
585
+ /**
586
+ * Pre-marker fallback — best-effort hydration when the SSR markup carries no
587
+ * per-slot boundary markers (a mismatched older SSR build). Current builds wrap
588
+ * every text slot, so this path is dead for matched server/client versions.
589
+ */
590
+ function legacyHydrateTextSlot(
591
+ commentMarker: Node,
592
+ value: unknown,
593
+ cleanups: Disposer[],
594
+ mountHooks: Array<EffectCallback>,
595
+ markerCursor: MarkerCursor,
596
+ ): void {
423
597
  if (isSignal(value) || typeof value === "function") {
424
598
  const fn = value as () => unknown;
425
599
  // First, evaluate eagerly to detect a structured value (nested
package/src/index.ts CHANGED
@@ -34,6 +34,7 @@ export {
34
34
  WebStorage,
35
35
  windowSize,
36
36
  } from "./browser.js";
37
+ export { type ClassValue, clsx, cn, twMerge } from "./cn.js";
37
38
  export type { Command } from "./command.js";
38
39
  export { command } from "./command.js";
39
40
  export { component, onMount, onUnmount } from "./component.js";
@@ -126,3 +127,4 @@ export {
126
127
  } from "./rpc.js";
127
128
  export { renderToString } from "./ssr.js";
128
129
  export type { TemplateResult } from "./types.js";
130
+ export { getRouteManifest, setRouteManifest, urlFor } from "./url.js";
package/src/render.ts CHANGED
@@ -118,6 +118,19 @@ export function mount(
118
118
  }
119
119
  continue;
120
120
  }
121
+ // Type guard: an attr/bool/prop/event slot needs an Element. On a
122
+ // structural desync the path can land on an EXISTING node of the wrong
123
+ // type (Text/Comment) — casting it to Element and calling setAttribute
124
+ // would throw "setAttribute is not a function". Degrade like the null
125
+ // case (skip the binding) instead of crashing the whole render.
126
+ if (slot.kind !== "text" && node.nodeType !== 1) {
127
+ if (typeof console !== "undefined") {
128
+ console.warn(
129
+ `[aurora] render: slot ${i} (${slot.kind}) path ${slot.path.join(".")} resolved to a non-element node — skipping binding`,
130
+ );
131
+ }
132
+ continue;
133
+ }
121
134
  if (slot.kind === "attr" && slot.staticParts !== undefined) {
122
135
  collectMultiAttr(slot, node as Element, result.values[i], multiGroups);
123
136
  } else {
@@ -25,6 +25,7 @@
25
25
 
26
26
  import type { Pages } from "../Pages.js";
27
27
  import { renderToString } from "../ssr.js";
28
+ import { setRouteManifest } from "../url.js";
28
29
 
29
30
  /**
30
31
  * Structural slice of the host framework's response. Same shape
@@ -62,6 +63,14 @@ export interface RenderPageOptions {
62
63
  * targets.
63
64
  */
64
65
  rootId?: string;
66
+ /**
67
+ * Named-route manifest (`name → path-pattern`) for the isomorphic
68
+ * `urlFor()` helper — build it with Ream's `router.namedManifest()`. It is
69
+ * installed server-side before the page renders AND serialized into the page
70
+ * so the hydrate bootstrap re-installs it, making `urlFor` work identically
71
+ * in SSR and the browser. Omit if the app doesn't use `urlFor`.
72
+ */
73
+ routes?: Record<string, string>;
65
74
  }
66
75
 
67
76
  export async function renderPage<P>(
@@ -71,6 +80,10 @@ export async function renderPage<P>(
71
80
  props: P,
72
81
  options: RenderPageOptions = {},
73
82
  ): Promise<void> {
83
+ // Install the route manifest BEFORE rendering so a page calling `urlFor`
84
+ // during SSR resolves against the same map the client will get.
85
+ if (options.routes) setRouteManifest(options.routes);
86
+
74
87
  const factory = await pages.resolve(name);
75
88
  // The factory must be invoked the SAME way client-side for hydrate
76
89
  // to find matching slots — `Page(props)` is the contract.
@@ -100,11 +113,13 @@ ${options.headExtra ?? ""}
100
113
  props,
101
114
  url: pageUrl,
102
115
  rootId,
116
+ routes: options.routes ?? {},
103
117
  })}</script>
104
118
  <script type="module">
105
- import { hydrate } from '@c9up/aurora'
119
+ import { hydrate, setRouteManifest } from '@c9up/aurora'
106
120
  import Page from ${JSON.stringify(pageUrl)}
107
121
  const data = JSON.parse(document.getElementById('aurora-page-data').textContent)
122
+ setRouteManifest(data.routes ?? {})
108
123
  hydrate(document.getElementById(data.rootId), () => Page(data.props))
109
124
  </script>
110
125
  </body>
package/src/ssr.ts CHANGED
@@ -66,41 +66,23 @@ function stringifyTemplateResult(result: TemplateResult): string {
66
66
  if (i < values.length && !skipValue) {
67
67
  const value = values[i];
68
68
  const inAttr = isInsideAttribute(out);
69
- if (!inAttr && isReactiveStructuredSlot(value)) {
70
- // Reactive text slot whose value is a nested template /
71
- // array — wrap the rendered content in boundary markers so
72
- // hydration can locate the exact node range and SWAP it when
73
- // the signal changes client-side. Without these markers a
74
- // nested-template slot hydrates once and then goes stale
75
- // (no way to find where the subtree starts/ends). Scalar
76
- // reactive slots (`${signal}` → text) are NOT wrapped: their
77
- // hydration updates the text node in place, no range needed.
78
- out += `<!--${SLOT_START}-->`;
79
- out += stringifyValue(value, false);
80
- out += `<!--${SLOT_END}-->`;
81
- } else if (!inAttr && isTemplateResult(value)) {
82
- // DIRECT (non-reactive) nested template component composition,
83
- // e.g. `${Layout({ children })}` or `${table}`. It renders to a
84
- // node RANGE just like a reactive structured slot, so it needs the
85
- // SAME boundary markers: the client template counts every slot as
86
- // ONE comment node, so without a markable range a multi-node child
87
- // shifts the childNode indices of every FOLLOWING sibling slot →
88
- // dead bindings / "slot path not found". Hydration collapses the
89
- // marked range back to one node so sibling paths stay aligned.
69
+ if (inAttr) {
70
+ out += stringifyValue(value, true);
71
+ } else {
72
+ // Text-region slot ALWAYS wrap in boundary markers so the SSR
73
+ // node structure matches the client template, which keeps exactly
74
+ // ONE comment node per slot. An inlined value otherwise MERGES
75
+ // with adjacent static text or sibling values when the browser
76
+ // parses the SSR HTML (`<p>Hello ${x}!</p>`ONE text node, not
77
+ // three), dropping the node count and desyncing the slot AND every
78
+ // following sibling binding (text, attr, event). Hydration
79
+ // collapses each `<!--$-->…<!--/$-->` range back to one node
80
+ // (collapseMarkerRanges) so paths align exactly; the range also
81
+ // anchors scalar text updates and nested-template swaps. Same
82
+ // part-marker approach as lit-html / Solid.
90
83
  out += `<!--${SLOT_START}-->`;
91
84
  out += stringifyValue(value, false);
92
85
  out += `<!--${SLOT_END}-->`;
93
- } else if (inAttr) {
94
- out += stringifyValue(value, true);
95
- } else {
96
- // Text-region scalar slot. An empty result (e.g. `cond ? x : ''`)
97
- // would emit NO node and desync the path-based hydration of the
98
- // following sibling slots (their @input/@submit bindings break).
99
- // Emit an empty-comment placeholder so the position is preserved
100
- // — lit-html / Solid do the same; hydration materializes the text
101
- // node there.
102
- const text = stringifyValue(value, false);
103
- out += text === "" ? "<!---->" : text;
104
86
  }
105
87
  }
106
88
  }
@@ -111,27 +93,6 @@ function stringifyTemplateResult(result: TemplateResult): string {
111
93
  const SLOT_START = "$";
112
94
  const SLOT_END = "/$";
113
95
 
114
- /**
115
- * True when `value` is a reactive expression (signal / function) whose
116
- * current evaluation is a structured node payload (a nested
117
- * TemplateResult, or an array). These are the slots that can SWAP their
118
- * subtree on a client-side change and therefore need boundary markers
119
- * for hydration to find the range. A reactive slot resolving to a
120
- * scalar (string / number) is updated in place and needs no markers.
121
- */
122
- function isReactiveStructuredSlot(value: unknown): boolean {
123
- if (!isSignal(value) && typeof value !== "function") return false;
124
- let evaluated: unknown;
125
- try {
126
- evaluated = isSignal(value)
127
- ? (value as () => unknown)()
128
- : (value as () => unknown)();
129
- } catch {
130
- return false;
131
- }
132
- return isTemplateResult(evaluated) || Array.isArray(evaluated);
133
- }
134
-
135
96
  /**
136
97
  * Returns true if the position at the end of `htmlSoFar` lives inside
137
98
  * the value region of an HTML tag (between `<` and `>`). The check
package/src/url.ts ADDED
@@ -0,0 +1,91 @@
1
+ /**
2
+ * `urlFor` — isomorphic named-route URL builder (AdonisJS v7 parity), the client
3
+ * half of Ream's `router.urlFor()`.
4
+ *
5
+ * Routes live server-side in Ream's router; this helper builds URLs from a
6
+ * serialized `name → path-pattern` manifest so the SAME `urlFor(name, params)`
7
+ * works in a page during SSR and after hydration in the browser — no more
8
+ * hard-coded paths like `redirect('/login')` / `href: '/team'`.
9
+ *
10
+ * urlFor('users.show', { id: 42 }) // → '/users/42'
11
+ * urlFor('auth.login') // → '/login'
12
+ * urlFor('search', {}, { q: 'ream', p: 2 })// → '/search?q=ream&p=2'
13
+ *
14
+ * The manifest is populated by {@link setRouteManifest}: `renderPage` calls it
15
+ * server-side from `options.routes` (build it with `router.namedManifest()`), and
16
+ * injects the same map into the page so the hydrate bootstrap re-sets it client
17
+ * side. Node-free — part of aurora's client runtime.
18
+ */
19
+
20
+ let manifest: Record<string, string> = {};
21
+
22
+ /**
23
+ * Install the `name → path-pattern` map `urlFor` resolves against (e.g.
24
+ * `{ 'users.show': '/users/:id' }`, from Ream's `router.namedManifest()`).
25
+ * Replaces any previous manifest. Routes are static per app, so this is set once
26
+ * per environment (server boot / page render, and the hydrate bootstrap).
27
+ */
28
+ export function setRouteManifest(routes: Record<string, string>): void {
29
+ manifest = { ...routes };
30
+ }
31
+
32
+ /** The currently-installed route manifest (mainly for tests/introspection). */
33
+ export function getRouteManifest(): Record<string, string> {
34
+ return { ...manifest };
35
+ }
36
+
37
+ /**
38
+ * Build a URL for a named route — fills `:param` placeholders, drops unprovided
39
+ * optional (`:name?`) segments, appends `query` as a query string, and throws on
40
+ * an unknown route or a missing required param. Mirrors Ream's `router.urlFor`.
41
+ */
42
+ export function urlFor(
43
+ name: string,
44
+ params?: Record<string, string | number>,
45
+ query?: Record<string, string | number>,
46
+ ): string {
47
+ const pattern = manifest[name];
48
+ if (pattern === undefined) {
49
+ const known = Object.keys(manifest);
50
+ throw new Error(
51
+ `[aurora] urlFor: unknown route '${name}'. ${
52
+ known.length > 0
53
+ ? `Known: ${known.join(", ")}`
54
+ : "No routes registered — was the manifest passed to render() / setRouteManifest() called?"
55
+ }`,
56
+ );
57
+ }
58
+
59
+ let url = pattern;
60
+ if (params) {
61
+ for (const [key, value] of Object.entries(params)) {
62
+ // Word-boundary substitution so `:id` doesn't corrupt `:idx`.
63
+ const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
64
+ url = url.replace(
65
+ new RegExp(`:${escaped}\\??(?![\\w])`, "g"),
66
+ encodeURIComponent(String(value)),
67
+ );
68
+ }
69
+ }
70
+
71
+ // Strip remaining optional placeholders (`:name?` not provided).
72
+ url = url.replace(/\/:[A-Za-z_][\w]*\?/g, "");
73
+
74
+ const missing = url.match(/:[A-Za-z_][\w]*/g);
75
+ if (missing && missing.length > 0) {
76
+ throw new Error(
77
+ `[aurora] urlFor: route '${name}' is missing params ${missing.join(", ")}`,
78
+ );
79
+ }
80
+
81
+ if (query) {
82
+ const qs = Object.entries(query)
83
+ .map(
84
+ ([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`,
85
+ )
86
+ .join("&");
87
+ if (qs) url += `${url.includes("?") ? "&" : "?"}${qs}`;
88
+ }
89
+
90
+ return url;
91
+ }