@barefootjs/client 0.34.0 → 0.35.0

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.
@@ -2020,6 +2020,11 @@ function escapeText(value) {
2020
2020
  return "";
2021
2021
  return escapeAttr(value);
2022
2022
  }
2023
+ function escapeCommentText(value) {
2024
+ if (value == null)
2025
+ return "";
2026
+ return String(value).replace(/-/g, "‐");
2027
+ }
2023
2028
  var BF_MARKUP_BRAND = "__bfMarkup";
2024
2029
  function bfMarkup(html) {
2025
2030
  return { [BF_MARKUP_BRAND]: html };
@@ -2153,6 +2158,46 @@ function cleanupPortalPlaceholder(portalId) {
2153
2158
  const placeholder = document.querySelector(`template[${BF_PORTAL_PLACEHOLDER}="${portalId}"]`);
2154
2159
  placeholder?.remove();
2155
2160
  }
2161
+ var pendingPortals = [];
2162
+ var pendingObserver = null;
2163
+ function moveToContainerEnd(container, element) {
2164
+ const moveBefore = container.moveBefore;
2165
+ if (typeof moveBefore === "function") {
2166
+ moveBefore.call(container, element, null);
2167
+ } else {
2168
+ container.appendChild(element);
2169
+ }
2170
+ }
2171
+ function flushPendingPortals() {
2172
+ for (const pending of pendingPortals.slice()) {
2173
+ if (!pending.subject.isConnected)
2174
+ continue;
2175
+ pendingPortals.splice(pendingPortals.indexOf(pending), 1);
2176
+ if (pending.element.parentNode === pending.container) {
2177
+ moveToContainerEnd(pending.container, pending.element);
2178
+ }
2179
+ }
2180
+ if (pendingPortals.length === 0 && pendingObserver) {
2181
+ pendingObserver.disconnect();
2182
+ pendingObserver = null;
2183
+ }
2184
+ }
2185
+ function enqueuePendingPortal(pending) {
2186
+ pendingPortals.push(pending);
2187
+ if (!pendingObserver) {
2188
+ pendingObserver = new MutationObserver(flushPendingPortals);
2189
+ pendingObserver.observe(document, { childList: true, subtree: true });
2190
+ }
2191
+ }
2192
+ function cancelPendingPortal(element) {
2193
+ const idx = pendingPortals.findIndex((p) => p.element === element);
2194
+ if (idx >= 0)
2195
+ pendingPortals.splice(idx, 1);
2196
+ if (pendingPortals.length === 0 && pendingObserver) {
2197
+ pendingObserver.disconnect();
2198
+ pendingObserver = null;
2199
+ }
2200
+ }
2156
2201
  function createPortal(children, container = document.body, options) {
2157
2202
  let element;
2158
2203
  if (children instanceof HTMLElement) {
@@ -2174,16 +2219,35 @@ function createPortal(children, container = document.body, options) {
2174
2219
  element.setAttribute(BF_PORTAL_OWNER, scopeId);
2175
2220
  }
2176
2221
  }
2222
+ const owner = options?.ownerScope;
2223
+ const formerParent = children instanceof HTMLElement ? children.parentNode : null;
2224
+ const subject = owner && !element.contains(owner) ? owner : formerParent instanceof Element ? formerParent : null;
2177
2225
  container.appendChild(element);
2226
+ if (subject && !subject.isConnected && typeof MutationObserver !== "undefined") {
2227
+ enqueuePendingPortal({ element, container, subject });
2228
+ }
2178
2229
  return {
2179
2230
  element,
2180
2231
  unmount() {
2232
+ cancelPendingPortal(element);
2181
2233
  if (element.parentNode) {
2182
2234
  element.parentNode.removeChild(element);
2183
2235
  }
2184
2236
  }
2185
2237
  };
2186
2238
  }
2239
+ // src/runtime/track-position.ts
2240
+ function trackPosition(update) {
2241
+ update();
2242
+ const onChange = () => update();
2243
+ window.addEventListener("scroll", onChange, true);
2244
+ window.addEventListener("resize", onChange);
2245
+ return () => {
2246
+ window.removeEventListener("scroll", onChange, true);
2247
+ window.removeEventListener("resize", onChange);
2248
+ update();
2249
+ };
2250
+ }
2187
2251
  // src/runtime/loop-markers.ts
2188
2252
  function findLoopMarkers(container, markerId) {
2189
2253
  let startMarker = null;
@@ -2454,17 +2518,20 @@ function createItemScope(item, index, renderItem, existingPrimary, existingExtra
2454
2518
  let primaryEl;
2455
2519
  let dispose;
2456
2520
  let setItem;
2521
+ let setIndex;
2457
2522
  let extras = [];
2458
2523
  let startMarker = null;
2459
2524
  let scopeComments = null;
2460
2525
  createRoot((d) => {
2461
2526
  dispose = d;
2462
2527
  const [itemAccessor, itemSetter] = createSignal(item);
2528
+ const [indexAccessor, indexSetter] = createSignal(index);
2463
2529
  setItem = itemSetter;
2530
+ setIndex = indexSetter;
2464
2531
  const ownsRowMount = !existingPrimary && !!rowMount;
2465
2532
  const prevRowMount = ownsRowMount ? setRowMountPoint(rowMount) : null;
2466
2533
  try {
2467
- primaryEl = renderItem(itemAccessor, index, existingPrimary);
2534
+ primaryEl = renderItem(itemAccessor, indexAccessor, existingPrimary);
2468
2535
  } catch (err) {
2469
2536
  const parked = rowMount?.mounted;
2470
2537
  if (parked?.parentNode)
@@ -2497,7 +2564,7 @@ function createItemScope(item, index, renderItem, existingPrimary, existingExtra
2497
2564
  if (rowMount && !existingPrimary && extras.length > 0 && primaryEl.parentNode) {
2498
2565
  primaryEl.remove();
2499
2566
  }
2500
- return { startMarker, primaryEl, extras, scopeComments, dispose, setItem };
2567
+ return { startMarker, primaryEl, extras, scopeComments, dispose, setItem, setIndex };
2501
2568
  }
2502
2569
  function mapArray(accessor, container, getKey, renderItem, markerId, bfId, keyAttrName2 = BF_KEY) {
2503
2570
  if (!container)
@@ -2575,7 +2642,8 @@ function mapArray(accessor, container, getKey, renderItem, markerId, bfId, keyAt
2575
2642
  extras: range.extras,
2576
2643
  scopeComments: range.scopeComments,
2577
2644
  dispose: () => {},
2578
- setItem: () => {}
2645
+ setItem: () => {},
2646
+ setIndex: () => {}
2579
2647
  });
2580
2648
  }
2581
2649
  }
@@ -2621,7 +2689,10 @@ function mapArray(accessor, container, getKey, renderItem, markerId, bfId, keyAt
2621
2689
  newKeys.add(key);
2622
2690
  const existing = scopes.get(key);
2623
2691
  if (existing) {
2624
- existing.setItem(item);
2692
+ batch(() => {
2693
+ existing.setItem(item);
2694
+ existing.setIndex(i2);
2695
+ });
2625
2696
  desiredOrder.push(existing);
2626
2697
  } else {
2627
2698
  const scope = createItemScope(item, i2, renderItem, undefined, undefined, undefined, undefined, { container, anchor });
@@ -2719,23 +2790,26 @@ function removeAnchorScope(scope, end) {
2719
2790
  function createAnchorScope(item, index, key, renderItem, existingAnchor) {
2720
2791
  let dispose;
2721
2792
  let setItem;
2793
+ let setIndex;
2722
2794
  let returned;
2723
2795
  createRoot((d) => {
2724
2796
  dispose = d;
2725
2797
  const [itemAccessor, itemSetter] = createSignal(item);
2798
+ const [indexAccessor, indexSetter] = createSignal(index);
2726
2799
  setItem = itemSetter;
2727
- returned = renderItem(itemAccessor, index, existingAnchor);
2800
+ setIndex = indexSetter;
2801
+ returned = renderItem(itemAccessor, indexAccessor, existingAnchor);
2728
2802
  return;
2729
2803
  });
2730
2804
  if (existingAnchor) {
2731
- return { anchor: existingAnchor, pending: null, dispose, setItem };
2805
+ return { anchor: existingAnchor, pending: null, dispose, setItem, setIndex };
2732
2806
  }
2733
2807
  const frag = returned;
2734
2808
  const anchor = frag.firstChild;
2735
2809
  if (anchor && !anchor.nodeValue?.startsWith(ITEM_PREFIX)) {
2736
2810
  anchor.nodeValue = loopItemMarker(key);
2737
2811
  }
2738
- return { anchor, pending: frag, dispose, setItem };
2812
+ return { anchor, pending: frag, dispose, setItem, setIndex };
2739
2813
  }
2740
2814
  function mapArrayAnchored(accessor, container, getKey, renderItem, markerId, bfId) {
2741
2815
  if (!container)
@@ -2786,7 +2860,10 @@ function mapArrayAnchored(accessor, container, getKey, renderItem, markerId, bfI
2786
2860
  newKeys.add(key);
2787
2861
  const existing = scopes.get(key);
2788
2862
  if (existing) {
2789
- existing.setItem(item);
2863
+ batch(() => {
2864
+ existing.setItem(item);
2865
+ existing.setIndex(i);
2866
+ });
2790
2867
  desiredOrder.push(existing);
2791
2868
  } else {
2792
2869
  const scope = createAnchorScope(item, i, key, renderItem);
@@ -2856,6 +2933,7 @@ function mapArrayLazy(accessor, container, getKey, plan, markerId, bfId, keyAttr
2856
2933
  key,
2857
2934
  primaryEl: undefined,
2858
2935
  item,
2936
+ index,
2859
2937
  refs: null,
2860
2938
  last: null
2861
2939
  };
@@ -2885,7 +2963,7 @@ function mapArrayLazy(accessor, container, getKey, plan, markerId, bfId, keyAttr
2885
2963
  const el = doms[i2];
2886
2964
  const ssrKey = el.getAttribute(keyAttrName2);
2887
2965
  const key = ssrKey !== null ? ssrKey : getKey ? getKey(items[i2], i2) : String(i2);
2888
- const entry = { key, primaryEl: el, item: items[i2], refs: null, last: null };
2966
+ const entry = { key, primaryEl: el, item: items[i2], index: i2, refs: null, last: null };
2889
2967
  entries.set(key, entry);
2890
2968
  list.push(entry);
2891
2969
  }
@@ -2940,11 +3018,19 @@ function mapArrayLazy(accessor, container, getKey, plan, markerId, bfId, keyAttr
2940
3018
  newKeys.add(key);
2941
3019
  const existing = entries.get(key);
2942
3020
  if (existing) {
2943
- if (!Object.is(existing.item, item)) {
3021
+ const itemChanged = !Object.is(existing.item, item);
3022
+ const indexChanged = plan.indexDriven === true && existing.index !== i2;
3023
+ if (itemChanged) {
2944
3024
  const prevItem = existing.item;
2945
3025
  existing.item = item;
3026
+ existing.index = i2;
2946
3027
  untrack(() => plan.applyItem(existing, prevItem));
2947
3028
  markStranded();
3029
+ } else if (indexChanged) {
3030
+ existing.index = i2;
3031
+ untrack(() => plan.applyItem(existing, item));
3032
+ } else {
3033
+ existing.index = i2;
2948
3034
  }
2949
3035
  desiredOrder.push(existing);
2950
3036
  } else {
@@ -3801,6 +3887,7 @@ export {
3801
3887
  upsertChild,
3802
3888
  unwrap,
3803
3889
  untrack,
3890
+ trackPosition,
3804
3891
  textOrNode,
3805
3892
  textNodeAfterComment as tAfter,
3806
3893
  styleToCss,
@@ -3854,6 +3941,7 @@ export {
3854
3941
  escapeTextOrNode,
3855
3942
  escapeTextOrMarkup,
3856
3943
  escapeText,
3944
+ escapeCommentText,
3857
3945
  escapeAttr,
3858
3946
  endTurn,
3859
3947
  disposeScope,
@@ -0,0 +1,37 @@
1
+ /**
2
+ * BarefootJS - Floating-element position tracking
3
+ *
4
+ * Keeps a `position: fixed` overlay (menu, popover, listbox, hover card)
5
+ * anchored to its trigger for as long as it is open. Shared by every
6
+ * site/ui overlay that positions itself from `getBoundingClientRect()`
7
+ * so the decision below is made in one place (#2848).
8
+ */
9
+ /**
10
+ * Run `update` now, re-run it on every scroll (capture phase, so a
11
+ * nested scroll container counts too) and on resize, and return the
12
+ * dispose that detaches both listeners.
13
+ *
14
+ * The dispose re-runs `update` ONCE, synchronously, before detaching —
15
+ * that final sample is the whole point of this helper. `scroll` events
16
+ * are coalesced per rendering frame and report the scroll position at
17
+ * dispatch time, not at scroll time. A programmatic scroll that landed
18
+ * in the current frame (a `focus()` on an offscreen item, a
19
+ * `scrollIntoView()`) has therefore not dispatched yet when a close
20
+ * runs in the same frame; the listener is gone by the time the event
21
+ * fires, and whatever position the listener would have written is lost.
22
+ * Without the final sample the closed element's inline position depends
23
+ * on whether a frame boundary happened to fall between that scroll and
24
+ * the close — measured as the `dropdown-menu` idempotence oracle
25
+ * landing on `top: -580px` / `-606px` / `33px` for the same action
26
+ * sequence. Sampling once at dispose makes the closed position a
27
+ * function of the geometry at close time only.
28
+ *
29
+ * (An `overflow: hidden` scroll lock does not narrow this window: it
30
+ * blocks user gestures, never programmatic scrolling, on `html` and
31
+ * `body` alike — verified in Chromium against the fixture-hydrate host.)
32
+ *
33
+ * @param update - Positions the element from current geometry.
34
+ * @returns Dispose: re-runs `update` once, then detaches the listeners.
35
+ */
36
+ export declare function trackPosition(update: () => void): () => void;
37
+ //# sourceMappingURL=track-position.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"track-position.d.ts","sourceRoot":"","sources":["../../src/runtime/track-position.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAgB,aAAa,CAAC,MAAM,EAAE,MAAM,IAAI,GAAG,MAAM,IAAI,CAU5D"}
package/dist/shims.d.ts CHANGED
@@ -18,4 +18,5 @@ export declare function createPortal(_children: PortalChildren, _container?: Ele
18
18
  export declare function isSSRPortal(_element: HTMLElement): boolean;
19
19
  export declare function findSiblingSlot(_el: HTMLElement, _slotSelector: string): HTMLElement | null;
20
20
  export declare function cleanupPortalPlaceholder(_portalId: string): void;
21
+ export declare function trackPosition(_update: () => void): () => void;
21
22
  //# sourceMappingURL=shims.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"shims.d.ts","sourceRoot":"","sources":["../src/shims.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,cAAc,CAAA;AAC3C,OAAO,KAAK,EAAE,MAAM,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAA;AAEhF,YAAY,EAAE,MAAM,EAAE,cAAc,EAAE,aAAa,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAA;AAS5F,wBAAgB,UAAU,CAAC,CAAC,EAAE,QAAQ,EAAE,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,CAErD;AAED,wBAAgB,cAAc,CAAC,CAAC,EAAE,QAAQ,EAAE,OAAO,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,GAAG,IAAI,CAEvE;AAED,wBAAgB,YAAY,CAC1B,SAAS,EAAE,cAAc,EACzB,UAAU,CAAC,EAAE,OAAO,EACpB,QAAQ,CAAC,EAAE,aAAa,GACvB,MAAM,CAER;AAED,wBAAgB,WAAW,CAAC,QAAQ,EAAE,WAAW,GAAG,OAAO,CAE1D;AAED,wBAAgB,eAAe,CAC7B,GAAG,EAAE,WAAW,EAChB,aAAa,EAAE,MAAM,GACpB,WAAW,GAAG,IAAI,CAEpB;AAED,wBAAgB,wBAAwB,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAEhE"}
1
+ {"version":3,"file":"shims.d.ts","sourceRoot":"","sources":["../src/shims.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,cAAc,CAAA;AAC3C,OAAO,KAAK,EAAE,MAAM,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAA;AAEhF,YAAY,EAAE,MAAM,EAAE,cAAc,EAAE,aAAa,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAA;AAS5F,wBAAgB,UAAU,CAAC,CAAC,EAAE,QAAQ,EAAE,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,CAErD;AAED,wBAAgB,cAAc,CAAC,CAAC,EAAE,QAAQ,EAAE,OAAO,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,GAAG,IAAI,CAEvE;AAED,wBAAgB,YAAY,CAC1B,SAAS,EAAE,cAAc,EACzB,UAAU,CAAC,EAAE,OAAO,EACpB,QAAQ,CAAC,EAAE,aAAa,GACvB,MAAM,CAER;AAED,wBAAgB,WAAW,CAAC,QAAQ,EAAE,WAAW,GAAG,OAAO,CAE1D;AAED,wBAAgB,eAAe,CAC7B,GAAG,EAAE,WAAW,EAChB,aAAa,EAAE,MAAM,GACpB,WAAW,GAAG,IAAI,CAEpB;AAED,wBAAgB,wBAAwB,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAEhE;AAED,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,IAAI,GAAG,MAAM,IAAI,CAE7D"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@barefootjs/client",
3
- "version": "0.34.0",
3
+ "version": "0.35.0",
4
4
  "description": "BarefootJS client package: reactive primitives (SSR-safe) plus browser runtime under the `/runtime` subpath (compiler target)",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -55,7 +55,7 @@
55
55
  "directory": "packages/client"
56
56
  },
57
57
  "dependencies": {
58
- "@barefootjs/shared": "0.34.0"
58
+ "@barefootjs/shared": "0.35.0"
59
59
  },
60
60
  "peerDependencies": {
61
61
  "@barefootjs/jsx": ">=0.2.0"
package/src/index.ts CHANGED
@@ -60,6 +60,7 @@ export {
60
60
  isSSRPortal,
61
61
  findSiblingSlot,
62
62
  cleanupPortalPlaceholder,
63
+ trackPosition,
63
64
  type Portal,
64
65
  type PortalChildren,
65
66
  type PortalOptions,
@@ -951,6 +951,33 @@ export function escapeText(value: unknown): string {
951
951
  return escapeAttr(value)
952
952
  }
953
953
 
954
+ /**
955
+ * Neutralize a value for splicing into an HTML COMMENT (`<!--...-->`), the
956
+ * whole-item loop conditional's `bf-loop-i:<key>` anchor (#1665,
957
+ * `itemAnchorTemplate` in `ir-to-client-js/html-template.ts`) being the one
958
+ * caller today. Standard HTML escaping (`escapeAttr`/`escapeText`) does
959
+ * NOT help here: `-`, `<`, `>` are not special in comment content the way
960
+ * `& < > " '` are in text/attributes — the only thing that terminates a
961
+ * comment early is the literal three-character sequence `-->`, which
962
+ * `escapeAttr` never touches.
963
+ *
964
+ * The key's exact text does not need to round-trip: nothing reads it back
965
+ * out of the DOM (`mapArrayAnchored` matches items positionally on first
966
+ * hydration and by its own JS-computed key afterward, never by re-parsing
967
+ * `Comment.nodeValue`, `map-array.ts`'s `isItemAnchor`/`findItemAnchors`).
968
+ * So replacing every ASCII hyphen with the visually-similar U+2010 HYPHEN
969
+ * is sufficient and simpler than a reversible escape: it makes `-->`
970
+ * unconstructible from the result (a hyphen adjacent to the template's own
971
+ * literal `-->` can no longer complete the sequence), and — because it is
972
+ * a different Unicode character rather than an HTML entity — needs no
973
+ * decoding: HTML comments do not interpret character references at all,
974
+ * so `&#45;` would appear as literal text `&#45;`, not `-`.
975
+ */
976
+ export function escapeCommentText(value: unknown): string {
977
+ if (value == null) return ''
978
+ return String(value).replace(/-/g, '‐')
979
+ }
980
+
954
981
  /**
955
982
  * Brand carried by `bfMarkup()` — see that function's docstring. A string
956
983
  * key (not a `Symbol`) because the brand must survive `structuredClone`
@@ -977,7 +1004,7 @@ export interface BfMarkup {
977
1004
  * itself assembled from a JSX element passed at a non-`children` component
978
1005
  * prop position (`header={<strong>Title</strong>}`) — every text segment
979
1006
  * inside that assembly was already escaped node-by-node during the
980
- * assembly (the same `escapeHtml` / `escapeTextSlotExpr` calls
1007
+ * assembly (the same `escapeHtml` calls / `spliceChildValue` door
981
1008
  * `html-template.ts` uses for ordinary element children), so the
982
1009
  * concatenated result is exactly as safe as any other compiler-emitted
983
1010
  * template fragment.
@@ -1009,8 +1036,8 @@ export function isBfMarkup(value: unknown): value is BfMarkup {
1009
1036
  /**
1010
1037
  * `escapeText`'s counterpart for a claim-plan slot the compiler has
1011
1038
  * classified `kind: 'markup'` at STATIC/initial-render time
1012
- * (`html-template.ts`'s `escapeTextSlotExpr`, gated on the same
1013
- * `ctx.dynamicElements` membership `emit-reactive.ts` reads to pick the
1039
+ * (`html-template.ts`'s `spliceChildValue` door, `safe-html.ts`, gated on
1040
+ * the same `ctx.dynamicElements` membership `emit-reactive.ts` reads to pick the
1014
1041
  * writer kind for the REACTIVE side, below) — #2651. A `bfMarkup()`-branded
1015
1042
  * value is compiler-built HTML the compiler already escaped piecewise
1016
1043
  * while assembling it, and must reach the template raw, unescaped a second
@@ -1027,9 +1054,8 @@ export function escapeTextOrMarkup(value: unknown): string {
1027
1054
 
1028
1055
  /**
1029
1056
  * Nullish guard for a bare `${children}` passthrough splice
1030
- * (`ir-to-client-js/html-template.ts`'s no-`slotId` `'expression'` branches
1031
- * — the "bare `${...}` interpolations" the docstring above `escapeTextSlotExpr`
1032
- * describes) — #2775. The value here is ALREADY-STRINGIFIED MARKUP, not an
1057
+ * (`ir-to-client-js/safe-html.ts`'s `spliceChildValue` door, no-`slotId`
1058
+ * `'expression'` branches) #2775. The value here is ALREADY-STRINGIFIED MARKUP, not an
1033
1059
  * arbitrary prop: `materializeComponent` (this file, "Template functions
1034
1060
  * expect children as an HTML string, not an array") joins a component's
1035
1061
  * `children` into an HTML string before the template lambda ever runs, so by
@@ -1063,7 +1089,7 @@ export function markupOrEmpty(value: unknown): string {
1063
1089
  * interpret HTML, so a raw un-escaped string is an injection/corruption
1064
1090
  * risk exactly where the initial SSR/CSR TEMPLATE already calls
1065
1091
  * `escapeText` on the same expression (`html-template.ts`'s
1066
- * `escapeTextSlotExpr`). A live `Node`, by contrast, must pass through
1092
+ * `spliceChildValue` door, `safe-html.ts`). A live `Node`, by contrast, must pass through
1067
1093
  * untouched — `escapeText(node)` would stringify it to garbage, and
1068
1094
  * `writeMarkup`'s own `instanceof Node` check needs the real object to
1069
1095
  * splice in by identity. This is the single call every "dynamic JSX/text
@@ -77,6 +77,9 @@ export {
77
77
  type PortalChildren,
78
78
  } from './portal.ts'
79
79
 
80
+ // Floating-element positioning (#2848)
81
+ export { trackPosition } from './track-position.ts'
82
+
80
83
  // Loop boundary marker lookup (used by mapArray/mapArrayAnchored consumers
81
84
  // and compiler-generated clearing code — see ./loop-markers.ts docstring)
82
85
  export { getLoopChildren, getLoopNodes } from './loop-markers.ts'
@@ -105,6 +108,7 @@ export {
105
108
  parseHTML,
106
109
  escapeAttr,
107
110
  escapeText,
111
+ escapeCommentText,
108
112
  escapeTextOrNode,
109
113
  // JSX-element-as-prop markup brand (#2651) — compiler-emitted code only.
110
114
  // NOT re-exported from the public `@barefootjs/client` top-level entry;
@@ -65,6 +65,18 @@
65
65
  * (a misclassification must be harmless, not silently wrong) and for the
66
66
  * three stranding sequences it prevents, each reproduced before it existed.
67
67
  *
68
+ * **Index-driven bindings (#2859 follow-up).** A row's position is tracked
69
+ * on `entry.index`, updated on every reconcile whether or not any binding
70
+ * reads it. A plan that sets `indexDriven: true` (emitted when
71
+ * `LazyRowPlanData.readsIndex`) is telling the runtime that some binding of
72
+ * `applyItem` reads `entry.index` — so the reconciler also calls
73
+ * `applyItem` on a pure reorder (item unchanged, position changed), not only
74
+ * on an item change. This mirrors the eager `mapArray` runtime's per-row
75
+ * index SIGNAL (`ItemScope.setIndex`) with no per-row reactive resource: the
76
+ * reconciler already knows a row's position changed the same way it already
77
+ * knows its item changed, so re-delivering that fact through a signal would
78
+ * be exactly the redundant machinery this design exists to avoid.
79
+ *
68
80
  * **Plan (compiler-emitted) obligations:**
69
81
  * - `createRow` MUST write ALL bindings — item-driven AND outer-involving —
70
82
  * with current values (it is CSR creation; it computes everything anyway)
@@ -126,6 +138,13 @@ export interface LazyRowEntry<T> {
126
138
  key: string
127
139
  primaryEl: HTMLElement
128
140
  item: T
141
+ /**
142
+ * The row's current position in the reconciled list (#2859 follow-up).
143
+ * Kept current on EVERY reconcile, whether or not the plan reads it —
144
+ * cheap bookkeeping, and it is what makes a bare reorder (no item change)
145
+ * detectable for `plan.indexDriven` loops.
146
+ */
147
+ index: number
129
148
  /** plan-owned: claimed DOM refs, null until the row's first item-driven write */
130
149
  refs: unknown | null
131
150
  /** plan-owned: per-binding last-value dedup state */
@@ -155,6 +174,16 @@ export interface LazyRowPlan<T> {
155
174
  * nodeValue) to initialize its dedup value and write only where the
156
175
  * computed value differs (read-compare-write, spec §9.3(1)). */
157
176
  applyOuter?(entries: ReadonlyArray<LazyRowEntry<T>>, seed: boolean): void
177
+ /**
178
+ * True when some binding, condition, or preamble-substituted dependency of
179
+ * this row reads the loop's INDEX (#2859 follow-up,
180
+ * `LazyRowPlanData.readsIndex`). Such a binding is always `readsItem`
181
+ * (compiler-side), so the runtime must also call `applyItem` when a row's
182
+ * POSITION changes with no item change — a plain reorder — not only when
183
+ * `!Object.is(oldItem, newItem)`. Absent/false for every other loop, which
184
+ * keeps a bare reorder exactly as cheap as before this widening.
185
+ */
186
+ indexDriven?: boolean
158
187
  }
159
188
 
160
189
  /**
@@ -268,6 +297,7 @@ export function mapArrayLazy<T>(
268
297
  // element and must not read primaryEl.
269
298
  primaryEl: undefined as unknown as HTMLElement,
270
299
  item,
300
+ index,
271
301
  refs: null,
272
302
  last: null,
273
303
  }
@@ -307,7 +337,7 @@ export function mapArrayLazy<T>(
307
337
  // rows carry a depth-suffixed name (#2753 Shape B).
308
338
  const ssrKey = el.getAttribute(keyAttrName)
309
339
  const key = ssrKey !== null ? ssrKey : getKey ? getKey(items[i], i) : String(i)
310
- const entry: LazyRowEntry<T> = { key, primaryEl: el, item: items[i], refs: null, last: null }
340
+ const entry: LazyRowEntry<T> = { key, primaryEl: el, item: items[i], index: i, refs: null, last: null }
311
341
  entries.set(key, entry)
312
342
  list.push(entry)
313
343
  }
@@ -383,11 +413,24 @@ export function mapArrayLazy<T>(
383
413
  // setItem. `applyItem` runs after `entry.item` is updated, receives
384
414
  // the previous item, and is untracked (mixed bindings may read
385
415
  // outer signals; the applyOuter effect owns the reactive side).
386
- if (!Object.is(existing.item, item)) {
416
+ const itemChanged = !Object.is(existing.item, item)
417
+ // A pure reorder (#2859 follow-up): the item is unchanged but the
418
+ // row's POSITION is, and `plan.indexDriven` says some binding reads
419
+ // it. `entry.index` is bookkept below regardless — only the
420
+ // `applyItem` call (and the stranding it implies) is conditional.
421
+ const indexChanged = plan.indexDriven === true && existing.index !== i
422
+ if (itemChanged) {
387
423
  const prevItem = existing.item
388
424
  existing.item = item
425
+ existing.index = i
389
426
  untrack(() => plan.applyItem(existing, prevItem))
390
427
  markStranded()
428
+ } else if (indexChanged) {
429
+ existing.index = i
430
+ // prevItem === item here: nothing but the position moved.
431
+ untrack(() => plan.applyItem(existing, item))
432
+ } else {
433
+ existing.index = i
391
434
  }
392
435
  desiredOrder.push(existing)
393
436
  } else {