@c9up/aurora 0.1.26 → 0.1.27

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/dist/hydrate.js CHANGED
@@ -257,6 +257,11 @@ function hydrateTemplateResult(result, liveNodes, cleanups, mountHooks, markerCu
257
257
  const syntheticRoot = {
258
258
  childNodes: liveNodes,
259
259
  };
260
+ // An attribute interpolating several slots — `class="static ${a} ${b}"` — is
261
+ // ONE attribute value built from all of them plus the static segments in
262
+ // between. Binding each slot on its own would have the last writer win and
263
+ // wipe the statics, which is what render.ts already avoids server-side.
264
+ const multiGroups = new Map();
260
265
  for (let i = 0; i < tpl.slots.length; i++) {
261
266
  const slot = tpl.slots[i];
262
267
  const liveNode = resolvePathLive(syntheticRoot, slot.path, liveNodes);
@@ -270,8 +275,47 @@ function hydrateTemplateResult(result, liveNodes, cleanups, mountHooks, markerCu
270
275
  }
271
276
  continue;
272
277
  }
278
+ if (slot.kind === "attr" && slot.staticParts !== undefined) {
279
+ collectMultiAttr(slot, liveNode, result.values[i], multiGroups);
280
+ continue;
281
+ }
273
282
  hydrateSlot(slot, liveNode, result.values[i], cleanups, mountHooks, markerCursor);
274
283
  }
284
+ for (const group of multiGroups.values()) {
285
+ applyMultiAttrGroup(group, cleanups);
286
+ }
287
+ }
288
+ function collectMultiAttr(slot, el, value, groups) {
289
+ if (!slot.staticParts)
290
+ return;
291
+ const key = `${slot.name}::${slot.path.join(".")}`;
292
+ let group = groups.get(key);
293
+ if (!group) {
294
+ group = { el, name: slot.name, staticParts: slot.staticParts, values: [] };
295
+ groups.set(key, group);
296
+ }
297
+ group.values.push(value);
298
+ }
299
+ function applyMultiAttrGroup(group, cleanups) {
300
+ function join() {
301
+ let out = group.staticParts[0] ?? "";
302
+ for (let i = 0; i < group.values.length; i++) {
303
+ const v = group.values[i];
304
+ const resolved = isSignal(v) || typeof v === "function" ? v() : v;
305
+ out += resolved == null || resolved === false ? "" : String(resolved);
306
+ out += group.staticParts[i + 1] ?? "";
307
+ }
308
+ return out;
309
+ }
310
+ const hasReactive = group.values.some((v) => isSignal(v) || typeof v === "function");
311
+ if (hasReactive) {
312
+ // SSR already wrote the joined value; re-joining on every tick is what
313
+ // keeps the statics in place when only one part changes.
314
+ cleanups.push(effect(() => {
315
+ group.el.setAttribute(group.name, join());
316
+ }));
317
+ }
318
+ // Fully static groups need nothing: SSR wrote the final value.
275
319
  }
276
320
  /**
277
321
  * Resolve a slot's path against the LIVE DOM. The first index of the
package/dist/render.js CHANGED
@@ -68,10 +68,17 @@ export function mount(result, cleanups, mounted, mountHooks) {
68
68
  // the final string. Collect them in a first pass, attach effects
69
69
  // after.
70
70
  const multiGroups = new Map();
71
+ // Resolve EVERY slot's node before applying any of them. Applying a text
72
+ // slot inserts nodes into the fragment, which shifts the child indices the
73
+ // remaining paths were computed against — so a slot sitting after a nested
74
+ // template (`${Icon()}${label}`) used to resolve to the wrong node, or to
75
+ // none, and silently never bound. Fragments exist precisely so a component
76
+ // needs no wrapper element; they must not cost the slots that follow them.
77
+ const resolved = tpl.slots.map((slot) => resolvePath(fragment, slot.path));
71
78
  for (let i = 0; i < tpl.slots.length; i++) {
72
79
  const slot = tpl.slots[i];
73
- const node = resolvePath(fragment, slot.path);
74
- if (node === null) {
80
+ const node = resolved[i];
81
+ if (node === null || node === undefined) {
75
82
  // Path didn't resolve — skip this binding rather than crash (see
76
83
  // resolvePath). Degrades to a dead binding; the surrounding render
77
84
  // (and any command driving it) survives.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@c9up/aurora",
3
- "version": "0.1.26",
3
+ "version": "0.1.27",
4
4
  "description": "Aurora — reactive UI runtime for the Ream framework. Tagged-template DOM, signal-based state, isomorphic SSR + hydration, zero build step.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/hydrate.ts CHANGED
@@ -358,6 +358,12 @@ function hydrateTemplateResult(
358
358
  childNodes: liveNodes,
359
359
  } as unknown as ParentNode;
360
360
 
361
+ // An attribute interpolating several slots — `class="static ${a} ${b}"` — is
362
+ // ONE attribute value built from all of them plus the static segments in
363
+ // between. Binding each slot on its own would have the last writer win and
364
+ // wipe the statics, which is what render.ts already avoids server-side.
365
+ const multiGroups = new Map<string, MultiAttrGroup>();
366
+
361
367
  for (let i = 0; i < tpl.slots.length; i++) {
362
368
  const slot = tpl.slots[i];
363
369
  const liveNode = resolvePathLive(syntheticRoot, slot.path, liveNodes);
@@ -373,6 +379,15 @@ function hydrateTemplateResult(
373
379
  }
374
380
  continue;
375
381
  }
382
+ if (slot.kind === "attr" && slot.staticParts !== undefined) {
383
+ collectMultiAttr(
384
+ slot,
385
+ liveNode as Element,
386
+ result.values[i],
387
+ multiGroups,
388
+ );
389
+ continue;
390
+ }
376
391
  hydrateSlot(
377
392
  slot,
378
393
  liveNode,
@@ -382,6 +397,65 @@ function hydrateTemplateResult(
382
397
  markerCursor,
383
398
  );
384
399
  }
400
+
401
+ for (const group of multiGroups.values()) {
402
+ applyMultiAttrGroup(group, cleanups);
403
+ }
404
+ }
405
+
406
+ /** One attribute whose value is assembled from several slots. */
407
+ interface MultiAttrGroup {
408
+ el: Element;
409
+ name: string;
410
+ staticParts: readonly string[];
411
+ values: unknown[];
412
+ }
413
+
414
+ function collectMultiAttr(
415
+ slot: AttrSlot,
416
+ el: Element,
417
+ value: unknown,
418
+ groups: Map<string, MultiAttrGroup>,
419
+ ): void {
420
+ if (!slot.staticParts) return;
421
+ const key = `${slot.name}::${(slot.path as readonly number[]).join(".")}`;
422
+ let group = groups.get(key);
423
+ if (!group) {
424
+ group = { el, name: slot.name, staticParts: slot.staticParts, values: [] };
425
+ groups.set(key, group);
426
+ }
427
+ group.values.push(value);
428
+ }
429
+
430
+ function applyMultiAttrGroup(
431
+ group: MultiAttrGroup,
432
+ cleanups: Disposer[],
433
+ ): void {
434
+ function join(): string {
435
+ let out = group.staticParts[0] ?? "";
436
+ for (let i = 0; i < group.values.length; i++) {
437
+ const v = group.values[i];
438
+ const resolved =
439
+ isSignal(v) || typeof v === "function" ? (v as () => unknown)() : v;
440
+ out += resolved == null || resolved === false ? "" : String(resolved);
441
+ out += group.staticParts[i + 1] ?? "";
442
+ }
443
+ return out;
444
+ }
445
+
446
+ const hasReactive = group.values.some(
447
+ (v) => isSignal(v) || typeof v === "function",
448
+ );
449
+ if (hasReactive) {
450
+ // SSR already wrote the joined value; re-joining on every tick is what
451
+ // keeps the statics in place when only one part changes.
452
+ cleanups.push(
453
+ effect(() => {
454
+ group.el.setAttribute(group.name, join());
455
+ }),
456
+ );
457
+ }
458
+ // Fully static groups need nothing: SSR wrote the final value.
385
459
  }
386
460
 
387
461
  /**
package/src/render.ts CHANGED
@@ -104,10 +104,20 @@ export function mount(
104
104
  // after.
105
105
  const multiGroups = new Map<string, MultiAttrGroup>();
106
106
 
107
+ // Resolve EVERY slot's node before applying any of them. Applying a text
108
+ // slot inserts nodes into the fragment, which shifts the child indices the
109
+ // remaining paths were computed against — so a slot sitting after a nested
110
+ // template (`${Icon()}${label}`) used to resolve to the wrong node, or to
111
+ // none, and silently never bound. Fragments exist precisely so a component
112
+ // needs no wrapper element; they must not cost the slots that follow them.
113
+ const resolved: Array<Node | null> = tpl.slots.map((slot) =>
114
+ resolvePath(fragment, slot.path),
115
+ );
116
+
107
117
  for (let i = 0; i < tpl.slots.length; i++) {
108
118
  const slot = tpl.slots[i];
109
- const node = resolvePath(fragment, slot.path);
110
- if (node === null) {
119
+ const node = resolved[i];
120
+ if (node === null || node === undefined) {
111
121
  // Path didn't resolve — skip this binding rather than crash (see
112
122
  // resolvePath). Degrades to a dead binding; the surrounding render
113
123
  // (and any command driving it) survives.