@c9up/aurora 0.1.31 → 0.1.32

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.
@@ -90,11 +90,22 @@ export class AuroraManager {
90
90
  shared: mergeSharedResolvers(this.shared, options?.shared),
91
91
  importmap: {
92
92
  "@c9up/aurora": `${this.auroraAssetPath}/index.js`,
93
- // The browser-facing subpath (RPC client) needs an explicit entry —
94
- // importmaps don't read package `exports`, and an extensionless bare
95
- // specifier won't hit a trailing-slash prefix map. Served from the
96
- // same aurora dist; harmless when a page never imports it.
93
+ // Every browser-facing subpath needs an explicit entry — importmaps
94
+ // don't read package `exports`, and an extensionless bare specifier
95
+ // won't hit a trailing-slash prefix map. Served from the same aurora
96
+ // dist; harmless when a page never imports one.
97
+ //
98
+ // `relay` is the one that has to be here: unlike `hydrate` and
99
+ // `ssr` it is NOT re-exported from the barrel, so a page that wants
100
+ // the relay client has no other specifier to reach it by.
101
+ //
102
+ // The node-only subpaths (`provider`, `server`, `services/main`)
103
+ // are deliberately absent: mapping them would hand the browser a
104
+ // specifier that resolves to a module it cannot load.
97
105
  "@c9up/aurora/rpc": `${this.auroraAssetPath}/rpc.js`,
106
+ "@c9up/aurora/relay": `${this.auroraAssetPath}/relay.js`,
107
+ "@c9up/aurora/hydrate": `${this.auroraAssetPath}/hydrate.js`,
108
+ "@c9up/aurora/ssr": `${this.auroraAssetPath}/ssr.js`,
98
109
  // Auto-map @c9up/comet when installed so the rpc client's bare
99
110
  // `import '@c9up/comet'` resolves in the no-bundler browser — no
100
111
  // app-side importmap wiring. Omitted when comet isn't present.
package/dist/hydrate.js CHANGED
@@ -177,21 +177,76 @@ function liveNodeCount(value) {
177
177
  }
178
178
  return 1; // scalar → one inlined text node
179
179
  }
180
+ /**
181
+ * The first and last top-level nodes an item contributes to the live DOM, as
182
+ * node types.
183
+ *
184
+ * Needed because the browser MERGES adjacent text nodes when it parses the SSR
185
+ * HTML. Two items whose markup touches — one ending in text, the next starting
186
+ * with text — share a single live node at their boundary, and counting them
187
+ * separately shifts every following item by one. That is why a list of one
188
+ * hydrates and a list of two does not: with one item there is no boundary.
189
+ *
190
+ * Read from the parsed TEMPLATE, which is what both sides agree on: a slot that
191
+ * renders to nothing still occupies a comment-marked range, and comments never
192
+ * merge with text.
193
+ */
194
+ function edgeNodeTypes(value) {
195
+ if (value === null || value === undefined || value === false)
196
+ return null;
197
+ if (isTemplateResult(value)) {
198
+ const children = getTemplate(value.strings).element.content.childNodes;
199
+ const first = children[0];
200
+ const last = children[children.length - 1];
201
+ if (!first || !last)
202
+ return null;
203
+ return { first: first.nodeType, last: last.nodeType };
204
+ }
205
+ if (Array.isArray(value)) {
206
+ // A nested array's edges are its own first and last contributing items.
207
+ let first = null;
208
+ let last = null;
209
+ for (const v of value) {
210
+ const edges = edgeNodeTypes(v);
211
+ if (!edges)
212
+ continue;
213
+ if (first === null)
214
+ first = edges.first;
215
+ last = edges.last;
216
+ }
217
+ return first === null || last === null ? null : { first, last };
218
+ }
219
+ // Scalar — inlined as one text node, so it merges on both sides.
220
+ return { first: 3 /* Text */, last: 3 /* Text */ };
221
+ }
180
222
  /**
181
223
  * Hydrate the items of a reactive array against the SSR nodes inside its marker
182
224
  * range. Each item is hydrated against its own slice of the (marker-collapsed)
183
225
  * range, IN ORDER, so every item's inner marker pairs are consumed in document
184
- * order and the global cursor stays aligned for slots AFTER the list. Item
185
- * templates need a stable top-level node count (the common
186
- * `arr.map(x => html`<li>…</li>`)` shape — single root, no surrounding
187
- * whitespace); bare adjacent scalar items can merge in the browser, so use
188
- * template items for hydrated lists.
226
+ * order and the global cursor stays aligned for slots AFTER the list.
227
+ *
228
+ * Item templates used to need a shape — single root, no surrounding whitespace,
229
+ * no bare adjacent scalars because the slice was a straight node count and
230
+ * the browser merges adjacent text nodes. A prettier-formatted item template
231
+ * was enough to break it, silently, from the second item onwards. The boundary
232
+ * is now accounted for (see `edgeNodeTypes`), so any item shape hydrates.
189
233
  */
190
234
  function hydrateArrayItems(items, rangeNodes, cleanups, mountHooks, markerCursor) {
191
235
  const nodes = collapseMarkerRanges(rangeNodes);
192
236
  let offset = 0;
237
+ /** The node type the previous item ended on, for the merge check below. */
238
+ let previousLast = null;
193
239
  for (const item of items) {
194
240
  const count = liveNodeCount(item);
241
+ const edges = edgeNodeTypes(item);
242
+ // Text-node merge at the item boundary: the previous item's trailing
243
+ // text and this one's leading text are ONE node in the live DOM, so this
244
+ // item starts where the previous one appeared to end. Without this the
245
+ // slice slides by one per boundary and every item after the first
246
+ // resolves its slot paths against the wrong nodes — reported as
247
+ // "slot 0 (attr) path 1.0 not found", once per slot per item.
248
+ if (previousLast === 3 && edges?.first === 3)
249
+ offset -= 1;
195
250
  if (isTemplateResult(item)) {
196
251
  hydrateTemplateResult(item, nodes.slice(offset, offset + count), cleanups, mountHooks, markerCursor);
197
252
  }
@@ -199,6 +254,10 @@ function hydrateArrayItems(items, rangeNodes, cleanups, mountHooks, markerCursor
199
254
  hydrateArrayItems(item, nodes.slice(offset, offset + count), cleanups, mountHooks, markerCursor);
200
255
  }
201
256
  offset += count;
257
+ // An item that contributes nothing (null/false) leaves the boundary
258
+ // where the last CONTRIBUTING item put it.
259
+ if (edges)
260
+ previousLast = edges.last;
202
261
  }
203
262
  }
204
263
  /**
@@ -255,13 +314,6 @@ function hydrateTemplateResult(result, liveNodes, cleanups, mountHooks, markerCu
255
314
  // Hydration walks via the SAME path resolver as render, but against
256
315
  // a synthetic root that mimics the parsed template's child list.
257
316
  const tpl = getTemplate(result.strings);
258
- // The live container's children should structurally match the
259
- // template's content children. Wrap them in a transient DocumentFragment
260
- // for path resolution — DocumentFragment.childNodes is the same view
261
- // we walked during parse.
262
- const syntheticRoot = {
263
- childNodes: liveNodes,
264
- };
265
317
  // An attribute interpolating several slots — `class="static ${a} ${b}"` — is
266
318
  // ONE attribute value built from all of them plus the static segments in
267
319
  // between. Binding each slot on its own would have the last writer win and
@@ -269,7 +321,7 @@ function hydrateTemplateResult(result, liveNodes, cleanups, mountHooks, markerCu
269
321
  const multiGroups = new Map();
270
322
  for (let i = 0; i < tpl.slots.length; i++) {
271
323
  const slot = tpl.slots[i];
272
- const liveNode = resolvePathLive(syntheticRoot, slot.path, liveNodes);
324
+ const liveNode = resolvePathLive(slot.path, liveNodes);
273
325
  if (!liveNode) {
274
326
  // Path missed in the live DOM — SSR markup diverges from the
275
327
  // parsed template's shape. Surfacing the mismatch beats silent
@@ -363,7 +415,7 @@ function collapseMarkerRanges(nodes) {
363
415
  }
364
416
  return out;
365
417
  }
366
- function resolvePathLive(_root, path, rootNodes) {
418
+ function resolvePathLive(path, rootNodes) {
367
419
  if (path.length === 0)
368
420
  return null;
369
421
  // Collapse marker ranges at EVERY level so the live child list matches the
@@ -568,7 +620,10 @@ function hydrateBooleanAttrSlot(slot, el, value, cleanups) {
568
620
  }
569
621
  function hydratePropSlot(slot, el, value, cleanups) {
570
622
  function apply(v) {
571
- el[slot.name] = v;
623
+ // Reflect.set rather than a cast: writing an arbitrary property onto an
624
+ // element is exactly what Reflect is for, and it does not require
625
+ // claiming the element is something it is not.
626
+ Reflect.set(el, slot.name, v);
572
627
  }
573
628
  if (isSignal(value) || typeof value === "function") {
574
629
  const dispose = effect(() => apply(value()));
package/dist/reactive.js CHANGED
@@ -84,7 +84,10 @@ export function signal(initial, options) {
84
84
  eff.run();
85
85
  }
86
86
  }
87
- accessor[SIGNAL_BRAND] = true;
87
+ // The brand is stamped through Reflect: the accessor is a function, and
88
+ // saying it is a branded object to write one property would be a lie the
89
+ // rest of the file then has to work around.
90
+ Reflect.set(accessor, SIGNAL_BRAND, true);
88
91
  signalNodes.set(accessor, node);
89
92
  return accessor;
90
93
  }
package/dist/render.js CHANGED
@@ -307,7 +307,8 @@ function applyBooleanAttrSlot(slot, el, value, cleanups) {
307
307
  }
308
308
  function applyPropSlot(slot, el, value, cleanups) {
309
309
  function apply(v) {
310
- el[slot.name] = v;
310
+ // See hydrate.ts: Reflect.set writes the property without a cast.
311
+ Reflect.set(el, slot.name, v);
311
312
  }
312
313
  if (isSignal(value) || typeof value === "function") {
313
314
  const dispose = effect(() => apply(value()));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@c9up/aurora",
3
- "version": "0.1.31",
3
+ "version": "0.1.32",
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",
@@ -168,11 +168,22 @@ export class AuroraManager {
168
168
  shared: mergeSharedResolvers(this.shared, options?.shared),
169
169
  importmap: {
170
170
  "@c9up/aurora": `${this.auroraAssetPath}/index.js`,
171
- // The browser-facing subpath (RPC client) needs an explicit entry —
172
- // importmaps don't read package `exports`, and an extensionless bare
173
- // specifier won't hit a trailing-slash prefix map. Served from the
174
- // same aurora dist; harmless when a page never imports it.
171
+ // Every browser-facing subpath needs an explicit entry — importmaps
172
+ // don't read package `exports`, and an extensionless bare specifier
173
+ // won't hit a trailing-slash prefix map. Served from the same aurora
174
+ // dist; harmless when a page never imports one.
175
+ //
176
+ // `relay` is the one that has to be here: unlike `hydrate` and
177
+ // `ssr` it is NOT re-exported from the barrel, so a page that wants
178
+ // the relay client has no other specifier to reach it by.
179
+ //
180
+ // The node-only subpaths (`provider`, `server`, `services/main`)
181
+ // are deliberately absent: mapping them would hand the browser a
182
+ // specifier that resolves to a module it cannot load.
175
183
  "@c9up/aurora/rpc": `${this.auroraAssetPath}/rpc.js`,
184
+ "@c9up/aurora/relay": `${this.auroraAssetPath}/relay.js`,
185
+ "@c9up/aurora/hydrate": `${this.auroraAssetPath}/hydrate.js`,
186
+ "@c9up/aurora/ssr": `${this.auroraAssetPath}/ssr.js`,
176
187
  // Auto-map @c9up/comet when installed so the rpc client's bare
177
188
  // `import '@c9up/comet'` resolves in the no-bundler browser — no
178
189
  // app-side importmap wiring. Omitted when comet isn't present.
package/src/hydrate.ts CHANGED
@@ -248,15 +248,56 @@ function liveNodeCount(value: unknown): number {
248
248
  return 1; // scalar → one inlined text node
249
249
  }
250
250
 
251
+ /**
252
+ * The first and last top-level nodes an item contributes to the live DOM, as
253
+ * node types.
254
+ *
255
+ * Needed because the browser MERGES adjacent text nodes when it parses the SSR
256
+ * HTML. Two items whose markup touches — one ending in text, the next starting
257
+ * with text — share a single live node at their boundary, and counting them
258
+ * separately shifts every following item by one. That is why a list of one
259
+ * hydrates and a list of two does not: with one item there is no boundary.
260
+ *
261
+ * Read from the parsed TEMPLATE, which is what both sides agree on: a slot that
262
+ * renders to nothing still occupies a comment-marked range, and comments never
263
+ * merge with text.
264
+ */
265
+ function edgeNodeTypes(value: unknown): { first: number; last: number } | null {
266
+ if (value === null || value === undefined || value === false) return null;
267
+ if (isTemplateResult(value)) {
268
+ const children = getTemplate(value.strings).element.content.childNodes;
269
+ const first = children[0];
270
+ const last = children[children.length - 1];
271
+ if (!first || !last) return null;
272
+ return { first: first.nodeType, last: last.nodeType };
273
+ }
274
+ if (Array.isArray(value)) {
275
+ // A nested array's edges are its own first and last contributing items.
276
+ let first: number | null = null;
277
+ let last: number | null = null;
278
+ for (const v of value) {
279
+ const edges = edgeNodeTypes(v);
280
+ if (!edges) continue;
281
+ if (first === null) first = edges.first;
282
+ last = edges.last;
283
+ }
284
+ return first === null || last === null ? null : { first, last };
285
+ }
286
+ // Scalar — inlined as one text node, so it merges on both sides.
287
+ return { first: 3 /* Text */, last: 3 /* Text */ };
288
+ }
289
+
251
290
  /**
252
291
  * Hydrate the items of a reactive array against the SSR nodes inside its marker
253
292
  * range. Each item is hydrated against its own slice of the (marker-collapsed)
254
293
  * range, IN ORDER, so every item's inner marker pairs are consumed in document
255
- * order and the global cursor stays aligned for slots AFTER the list. Item
256
- * templates need a stable top-level node count (the common
257
- * `arr.map(x => html`<li>…</li>`)` shape — single root, no surrounding
258
- * whitespace); bare adjacent scalar items can merge in the browser, so use
259
- * template items for hydrated lists.
294
+ * order and the global cursor stays aligned for slots AFTER the list.
295
+ *
296
+ * Item templates used to need a shape — single root, no surrounding whitespace,
297
+ * no bare adjacent scalars because the slice was a straight node count and
298
+ * the browser merges adjacent text nodes. A prettier-formatted item template
299
+ * was enough to break it, silently, from the second item onwards. The boundary
300
+ * is now accounted for (see `edgeNodeTypes`), so any item shape hydrates.
260
301
  */
261
302
  function hydrateArrayItems(
262
303
  items: unknown[],
@@ -267,8 +308,18 @@ function hydrateArrayItems(
267
308
  ): void {
268
309
  const nodes = collapseMarkerRanges(rangeNodes);
269
310
  let offset = 0;
311
+ /** The node type the previous item ended on, for the merge check below. */
312
+ let previousLast: number | null = null;
270
313
  for (const item of items) {
271
314
  const count = liveNodeCount(item);
315
+ const edges = edgeNodeTypes(item);
316
+ // Text-node merge at the item boundary: the previous item's trailing
317
+ // text and this one's leading text are ONE node in the live DOM, so this
318
+ // item starts where the previous one appeared to end. Without this the
319
+ // slice slides by one per boundary and every item after the first
320
+ // resolves its slot paths against the wrong nodes — reported as
321
+ // "slot 0 (attr) path 1.0 not found", once per slot per item.
322
+ if (previousLast === 3 && edges?.first === 3) offset -= 1;
272
323
  if (isTemplateResult(item)) {
273
324
  hydrateTemplateResult(
274
325
  item,
@@ -287,6 +338,9 @@ function hydrateArrayItems(
287
338
  );
288
339
  }
289
340
  offset += count;
341
+ // An item that contributes nothing (null/false) leaves the boundary
342
+ // where the last CONTRIBUTING item put it.
343
+ if (edges) previousLast = edges.last;
290
344
  }
291
345
  }
292
346
 
@@ -355,13 +409,6 @@ function hydrateTemplateResult(
355
409
  // Hydration walks via the SAME path resolver as render, but against
356
410
  // a synthetic root that mimics the parsed template's child list.
357
411
  const tpl = getTemplate(result.strings);
358
- // The live container's children should structurally match the
359
- // template's content children. Wrap them in a transient DocumentFragment
360
- // for path resolution — DocumentFragment.childNodes is the same view
361
- // we walked during parse.
362
- const syntheticRoot = {
363
- childNodes: liveNodes,
364
- } as unknown as ParentNode;
365
412
 
366
413
  // An attribute interpolating several slots — `class="static ${a} ${b}"` — is
367
414
  // ONE attribute value built from all of them plus the static segments in
@@ -371,7 +418,7 @@ function hydrateTemplateResult(
371
418
 
372
419
  for (let i = 0; i < tpl.slots.length; i++) {
373
420
  const slot = tpl.slots[i];
374
- const liveNode = resolvePathLive(syntheticRoot, slot.path, liveNodes);
421
+ const liveNode = resolvePathLive(slot.path, liveNodes);
375
422
  if (!liveNode) {
376
423
  // Path missed in the live DOM — SSR markup diverges from the
377
424
  // parsed template's shape. Surfacing the mismatch beats silent
@@ -502,11 +549,7 @@ function collapseMarkerRanges(nodes: ChildNode[]): ChildNode[] {
502
549
  return out;
503
550
  }
504
551
 
505
- function resolvePathLive(
506
- _root: ParentNode,
507
- path: NodePath,
508
- rootNodes: ChildNode[],
509
- ): Node | null {
552
+ function resolvePathLive(path: NodePath, rootNodes: ChildNode[]): Node | null {
510
553
  if (path.length === 0) return null;
511
554
  // Collapse marker ranges at EVERY level so the live child list matches the
512
555
  // parsed template's one-node-per-slot shape (see collapseMarkerRanges).
@@ -789,7 +832,10 @@ function hydratePropSlot(
789
832
  cleanups: Disposer[],
790
833
  ): void {
791
834
  function apply(v: unknown): void {
792
- (el as unknown as Record<string, unknown>)[slot.name] = v;
835
+ // Reflect.set rather than a cast: writing an arbitrary property onto an
836
+ // element is exactly what Reflect is for, and it does not require
837
+ // claiming the element is something it is not.
838
+ Reflect.set(el, slot.name, v);
793
839
  }
794
840
  if (isSignal(value) || typeof value === "function") {
795
841
  const dispose = effect(() => apply((value as () => unknown)()));
package/src/reactive.ts CHANGED
@@ -132,7 +132,10 @@ export function signal<T>(
132
132
  }
133
133
  }
134
134
 
135
- (accessor as unknown as { [SIGNAL_BRAND]: true })[SIGNAL_BRAND] = true;
135
+ // The brand is stamped through Reflect: the accessor is a function, and
136
+ // saying it is a branded object to write one property would be a lie the
137
+ // rest of the file then has to work around.
138
+ Reflect.set(accessor, SIGNAL_BRAND, true);
136
139
  signalNodes.set(accessor, node);
137
140
  return accessor as Signal<T>;
138
141
  }
package/src/render.ts CHANGED
@@ -405,7 +405,8 @@ function applyPropSlot(
405
405
  cleanups: Disposer[],
406
406
  ): void {
407
407
  function apply(v: unknown): void {
408
- (el as unknown as Record<string, unknown>)[slot.name] = v;
408
+ // See hydrate.ts: Reflect.set writes the property without a cast.
409
+ Reflect.set(el, slot.name, v);
409
410
  }
410
411
  if (isSignal(value) || typeof value === "function") {
411
412
  const dispose = effect(() => apply((value as () => unknown)()));