@barefootjs/client 0.33.6 → 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.
@@ -18,7 +18,7 @@
18
18
  * Single-root loops continue to flow through the legacy path verbatim.
19
19
  */
20
20
 
21
- import { createSignal, createEffect, createRoot } from '@barefootjs/client/reactive'
21
+ import { createSignal, createEffect, createRoot, batch } from '@barefootjs/client/reactive'
22
22
  import { hydratedScopes } from './hydration-state.ts'
23
23
  import { setRowMountPoint, type RowMountPoint } from './component.ts'
24
24
  import {
@@ -80,8 +80,30 @@ type ItemScope<T> = {
80
80
  scopeComments: ScopeCommentPair | null
81
81
  dispose: () => void
82
82
  setItem: (v: T) => void
83
+ /**
84
+ * Push this row's CURRENT position (#2859). A same-key reorder never
85
+ * re-invokes `renderItem` — only `setItem`/`setIndex` run — so anything
86
+ * the row body derives from the raw `.map()` index parameter (rather
87
+ * than from the item itself) stays live only because the compiler
88
+ * rewrites references to that parameter into a call through the
89
+ * accessor `renderItem` received (`wrapLoopParamAsAccessor`,
90
+ * ir-to-client-js/utils.ts) instead of using the plain number this
91
+ * signal was seeded from.
92
+ */
93
+ setIndex: (i: number) => void
83
94
  }
84
95
 
96
+ /**
97
+ * Shape `renderItem` callbacks are compiled to. `index` mirrors `item`:
98
+ * an accessor, not a plain number, so a row body's reference to the raw
99
+ * `.map()` index parameter — rewritten to a call by
100
+ * `wrapLoopParamAsAccessor` — reads the row's CURRENT position instead of
101
+ * the value captured when the row was first created (#2859). `existing`
102
+ * stays optional/untyped-per-call-site since `mapArray` and
103
+ * `mapArrayAnchored` return different element shapes.
104
+ */
105
+ export type RenderItem<T, E, R> = (item: () => T, index: () => number, existing?: E) => R
106
+
85
107
  /**
86
108
  * Find loop boundary comment markers in a container.
87
109
  *
@@ -327,7 +349,7 @@ function removeScope<T>(scope: ItemScope<T>): void {
327
349
  function createItemScope<T>(
328
350
  item: T,
329
351
  index: number,
330
- renderItem: (item: () => T, index: number, existing?: HTMLElement) => HTMLElement,
352
+ renderItem: RenderItem<T, HTMLElement, HTMLElement>,
331
353
  existingPrimary?: HTMLElement,
332
354
  existingExtras?: HTMLElement[],
333
355
  existingStart?: Comment | null,
@@ -337,6 +359,7 @@ function createItemScope<T>(
337
359
  let primaryEl!: HTMLElement
338
360
  let dispose!: () => void
339
361
  let setItem!: (v: T) => void
362
+ let setIndex!: (i: number) => void
340
363
  let extras: HTMLElement[] = []
341
364
  let startMarker: Comment | null = null
342
365
  let scopeComments: ScopeCommentPair | null = null
@@ -344,7 +367,9 @@ function createItemScope<T>(
344
367
  createRoot((d) => {
345
368
  dispose = d
346
369
  const [itemAccessor, itemSetter] = createSignal(item)
370
+ const [indexAccessor, indexSetter] = createSignal(index)
347
371
  setItem = itemSetter
372
+ setIndex = indexSetter
348
373
  // Fresh row: hand the mount point down so the row's own root — whether a
349
374
  // `createComponent` call or a `mountRowRoot(clone)` — observes a connected
350
375
  // element when the body's tail initialises its children. A body that does
@@ -358,7 +383,7 @@ function createItemScope<T>(
358
383
  const ownsRowMount = !existingPrimary && !!rowMount
359
384
  const prevRowMount = ownsRowMount ? setRowMountPoint(rowMount) : null
360
385
  try {
361
- primaryEl = renderItem(itemAccessor, index, existingPrimary)
386
+ primaryEl = renderItem(itemAccessor, indexAccessor, existingPrimary)
362
387
  } catch (err) {
363
388
  // A row that connected itself and then failed to finish would stay
364
389
  // visible as a half-built row. Detached rows never could, so undo the
@@ -399,7 +424,7 @@ function createItemScope<T>(
399
424
  primaryEl.remove()
400
425
  }
401
426
 
402
- return { startMarker, primaryEl, extras, scopeComments, dispose, setItem }
427
+ return { startMarker, primaryEl, extras, scopeComments, dispose, setItem, setIndex }
403
428
  }
404
429
 
405
430
  /**
@@ -427,7 +452,7 @@ export function mapArray<T>(
427
452
  accessor: () => T[],
428
453
  container: HTMLElement | null,
429
454
  getKey: ((item: T, index: number) => string) | null,
430
- renderItem: (item: () => T, index: number, existing?: HTMLElement) => HTMLElement,
455
+ renderItem: RenderItem<T, HTMLElement, HTMLElement>,
431
456
  markerId?: string,
432
457
  bfId?: string,
433
458
  keyAttrName: string = BF_KEY,
@@ -573,6 +598,7 @@ export function mapArray<T>(
573
598
  scopeComments: range.scopeComments,
574
599
  dispose: () => {},
575
600
  setItem: () => {},
601
+ setIndex: () => {},
576
602
  })
577
603
  }
578
604
  }
@@ -645,8 +671,13 @@ export function mapArray<T>(
645
671
  const existing = scopes.get(key)
646
672
  if (existing) {
647
673
  // Same key: update per-item signal — fine-grained effects handle DOM updates.
648
- // Element is preserved (no dispose, no re-render).
649
- existing.setItem(item)
674
+ // Element is preserved (no dispose, no re-render). `setIndex` pushes this
675
+ // row's CURRENT position (#2859) — batched with `setItem` so a row whose
676
+ // effect reads both doesn't run twice for one reconcile pass.
677
+ batch(() => {
678
+ existing.setItem(item)
679
+ existing.setIndex(i)
680
+ })
650
681
  desiredOrder.push(existing)
651
682
  } else {
652
683
  // New item: create in isolated scope. The row is mounted at the end of
@@ -779,6 +810,8 @@ type AnchorScope<T> = {
779
810
  pending: DocumentFragment | null
780
811
  dispose: () => void
781
812
  setItem: (v: T) => void
813
+ /** See `ItemScope.setIndex` (#2859) — same per-row "current position" push. */
814
+ setIndex: (i: number) => void
782
815
  }
783
816
 
784
817
  const ITEM_PREFIX = `${BF_LOOP_ITEM}:`
@@ -845,23 +878,26 @@ function createAnchorScope<T>(
845
878
  item: T,
846
879
  index: number,
847
880
  key: string,
848
- renderItem: (item: () => T, index: number, existing?: Comment) => DocumentFragment | Comment,
881
+ renderItem: RenderItem<T, Comment, DocumentFragment | Comment>,
849
882
  existingAnchor?: Comment,
850
883
  ): AnchorScope<T> {
851
884
  let dispose!: () => void
852
885
  let setItem!: (v: T) => void
886
+ let setIndex!: (i: number) => void
853
887
  let returned!: DocumentFragment | Comment
854
888
 
855
889
  createRoot((d) => {
856
890
  dispose = d
857
891
  const [itemAccessor, itemSetter] = createSignal(item)
892
+ const [indexAccessor, indexSetter] = createSignal(index)
858
893
  setItem = itemSetter
859
- returned = renderItem(itemAccessor, index, existingAnchor)
894
+ setIndex = indexSetter
895
+ returned = renderItem(itemAccessor, indexAccessor, existingAnchor)
860
896
  return undefined
861
897
  })
862
898
 
863
899
  if (existingAnchor) {
864
- return { anchor: existingAnchor, pending: null, dispose, setItem }
900
+ return { anchor: existingAnchor, pending: null, dispose, setItem, setIndex }
865
901
  }
866
902
  // CSR: renderItem returns a fragment whose first child is the anchor.
867
903
  const frag = returned as DocumentFragment
@@ -871,7 +907,7 @@ function createAnchorScope<T>(
871
907
  if (anchor && !anchor.nodeValue?.startsWith(ITEM_PREFIX)) {
872
908
  anchor.nodeValue = loopItemMarker(key)
873
909
  }
874
- return { anchor, pending: frag, dispose, setItem }
910
+ return { anchor, pending: frag, dispose, setItem, setIndex }
875
911
  }
876
912
 
877
913
  /**
@@ -886,7 +922,7 @@ export function mapArrayAnchored<T>(
886
922
  accessor: () => T[],
887
923
  container: HTMLElement | null,
888
924
  getKey: ((item: T, index: number) => string) | null,
889
- renderItem: (item: () => T, index: number, existing?: Comment) => DocumentFragment | Comment,
925
+ renderItem: RenderItem<T, Comment, DocumentFragment | Comment>,
890
926
  markerId?: string,
891
927
  bfId?: string,
892
928
  ): void {
@@ -949,7 +985,12 @@ export function mapArrayAnchored<T>(
949
985
 
950
986
  const existing = scopes.get(key)
951
987
  if (existing) {
952
- existing.setItem(item)
988
+ // See the identical `mapArray` branch above (#2859): batched so a row
989
+ // whose effect reads both item and index doesn't run twice.
990
+ batch(() => {
991
+ existing.setItem(item)
992
+ existing.setIndex(i)
993
+ })
953
994
  desiredOrder.push(existing)
954
995
  } else {
955
996
  const scope = createAnchorScope(item, i, key, renderItem)
@@ -126,6 +126,143 @@ export function cleanupPortalPlaceholder(portalId: string): void {
126
126
  placeholder?.remove()
127
127
  }
128
128
 
129
+ /**
130
+ * A portal whose deferral subject (see `createPortal`'s insertion rule) was
131
+ * not yet in the document when `createPortal` ran (#2717). Its element is
132
+ * ALREADY a child of `container`; once the subject connects it is
133
+ * re-appended, which moves it to the container's end.
134
+ */
135
+ interface PendingPortal {
136
+ element: HTMLElement
137
+ container: HTMLElement
138
+ /** The node whose connection is awaited: the owner, or the element's former parent. */
139
+ subject: Node
140
+ }
141
+
142
+ /** Creation-ordered queue of portals waiting for their subject to connect. */
143
+ const pendingPortals: PendingPortal[] = []
144
+ /** Alive only while `pendingPortals` is non-empty. */
145
+ let pendingObserver: MutationObserver | null = null
146
+
147
+ /**
148
+ * `Element.moveBefore()` — not yet in TS's bundled `lib.dom.d.ts` — moves an
149
+ * already-connected node without the side effects `appendChild`/
150
+ * `insertBefore` are specified to have on one: it resets `<iframe>` load
151
+ * state, `<video>`/`<audio>` playback position, CSS animation/transition
152
+ * state, `:focus`/`:active`, and native `popover`/fullscreen state. The
153
+ * reorder below is a genuine move of an already-connected node (the
154
+ * element is already `container`'s child from `createPortal`'s initial
155
+ * append), so it must prefer `moveBefore` and fall back to `appendChild`
156
+ * only where the API isn't supported yet — same fallback shape as the
157
+ * `MutationObserver` feature-detection above.
158
+ */
159
+ interface MoveBeforeCapable {
160
+ moveBefore(node: Node, child: Node | null): void
161
+ }
162
+
163
+ /** Move `element` to `container`'s end, preserving any live state a plain re-`appendChild` would reset (see `MoveBeforeCapable`). */
164
+ function moveToContainerEnd(container: HTMLElement, element: HTMLElement): void {
165
+ const moveBefore = (container as Partial<MoveBeforeCapable>).moveBefore
166
+ if (typeof moveBefore === 'function') {
167
+ moveBefore.call(container, element, null)
168
+ } else {
169
+ container.appendChild(element)
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Re-append every pending portal whose subject has connected since the
175
+ * last check, in creation order — the same order the hydration path
176
+ * produces when the owner is already connected and each `createPortal`
177
+ * appends synchronously. The element is already in `container`, so this
178
+ * moves the connected node to the container's end (see `moveToContainerEnd`),
179
+ * after the root that connected it, rather than inserting a new one.
180
+ * Pending entries whose subject is still detached are kept; an entry
181
+ * whose element has left the container in the meantime (removed or moved
182
+ * by the caller without `unmount`) is dropped rather than re-inserted.
183
+ */
184
+ function flushPendingPortals(): void {
185
+ for (const pending of pendingPortals.slice()) {
186
+ if (!pending.subject.isConnected) continue
187
+ pendingPortals.splice(pendingPortals.indexOf(pending), 1)
188
+ if (pending.element.parentNode === pending.container) {
189
+ moveToContainerEnd(pending.container, pending.element)
190
+ }
191
+ }
192
+ if (pendingPortals.length === 0 && pendingObserver) {
193
+ pendingObserver.disconnect()
194
+ pendingObserver = null
195
+ }
196
+ }
197
+
198
+ function enqueuePendingPortal(pending: PendingPortal): void {
199
+ pendingPortals.push(pending)
200
+ if (!pendingObserver) {
201
+ // Observe the whole document: the subject is connected by whoever holds
202
+ // the component root (`document.body.appendChild(root)` in a CSR boot,
203
+ // a placeholder `replaceWith` higher up the tree, …) — the runtime has
204
+ // no hook of its own at that moment, so the DOM's own insertion
205
+ // notification is the one signal that covers every caller. Callbacks
206
+ // run as a microtask, before the next paint, so the element is never
207
+ // rendered at its pre-reorder position.
208
+ pendingObserver = new MutationObserver(flushPendingPortals)
209
+ pendingObserver.observe(document, { childList: true, subtree: true })
210
+ }
211
+ }
212
+
213
+ function cancelPendingPortal(element: HTMLElement): void {
214
+ const idx = pendingPortals.findIndex(p => p.element === element)
215
+ if (idx >= 0) pendingPortals.splice(idx, 1)
216
+ if (pendingPortals.length === 0 && pendingObserver) {
217
+ pendingObserver.disconnect()
218
+ pendingObserver = null
219
+ }
220
+ }
221
+
222
+ /**
223
+ * Insertion rule (#2717): the portal's content is appended to `container`
224
+ * synchronously, at call time, and — when the component it belongs to is
225
+ * not yet in the document — re-appended once that component connects,
226
+ * which moves it to the container's end.
227
+ *
228
+ * - Owner already connected (hydration: SSR markup is in the document
229
+ * before `init` runs), or nothing to wait for: a single append, at the
230
+ * container's end — unchanged behaviour.
231
+ * - Owner not yet connected (a client-side mount: `materializeComponent`
232
+ * runs `init` — and so the `ref` callbacks that call this — BEFORE the
233
+ * bare-`createComponent` caller connects the root): the element is
234
+ * appended now all the same, and a reorder is queued; once the owner
235
+ * connects, pending portals are re-appended in creation order.
236
+ *
237
+ * Without the reorder the two construction paths disagree on
238
+ * `document.body`'s child order: hydration yields `[root, …portals]`
239
+ * while a CSR mount yields `[…portals, root]`, because the root is
240
+ * appended AFTER its portals were. Child order is user-visible (paint
241
+ * order between equal-`z-index` overlays, focus traversal, `querySelector`
242
+ * results), so both paths converge on the hydration answer.
243
+ *
244
+ * The append itself is never deferred: the element is connected to the
245
+ * document at every point in time, only its position among the
246
+ * container's children settles asynchronously (as a microtask, before the
247
+ * next paint). Portal consumers measure layout synchronously in the same
248
+ * tick as their `ref` callback — `createEffect`'s first run is synchronous,
249
+ * and the floating-position components (popover, dropdown-menu, context-
250
+ * menu, …) read `offsetWidth`/`offsetHeight` of the portaled element there,
251
+ * gated only on their open signal — so a portal that was briefly absent
252
+ * from the document would hand them zero-sized boxes with nothing to
253
+ * re-trigger the measurement once it landed.
254
+ *
255
+ * The subject whose connection is awaited is the `ownerScope` when given
256
+ * and it lies outside the element; otherwise the element's former parent.
257
+ * The self-owner shape (`ownerScope === element`: a child component whose
258
+ * own root carries `bf-s`, e.g. DialogOverlay/DialogContent) needs that
259
+ * fallback, since the immediate append connects the element — and so the
260
+ * owner — right away, while the component under construction is the tree
261
+ * it was taken from. An element in a detached tree with no owner (a
262
+ * fragment-root component, whose scope lives on a comment) is the same
263
+ * case. A bare element with neither has nothing that will ever connect
264
+ * it, so there is nothing to wait for.
265
+ */
129
266
  export function createPortal(
130
267
  children: PortalChildren,
131
268
  container: HTMLElement = document.body,
@@ -161,11 +298,28 @@ export function createPortal(
161
298
  }
162
299
  }
163
300
 
301
+ // The reorder subject, resolved BEFORE the append moves the element:
302
+ // the declared owner when it lies outside the element, else the
303
+ // element's former parent (see the insertion rule above). Only a parent
304
+ // the caller handed us counts — the string path parses into a fragment,
305
+ // which never connects; the same goes for a caller-built fragment.
306
+ const owner = options?.ownerScope
307
+ const formerParent = children instanceof HTMLElement ? children.parentNode : null
308
+ const subject: Node | null =
309
+ owner && !element.contains(owner) ? owner : formerParent instanceof Element ? formerParent : null
310
+
164
311
  container.appendChild(element)
165
312
 
313
+ // `MutationObserver` is the reorder primitive; an environment without it
314
+ // keeps the plain synchronous append (the pre-#2717 behaviour).
315
+ if (subject && !subject.isConnected && typeof MutationObserver !== 'undefined') {
316
+ enqueuePendingPortal({ element, container, subject })
317
+ }
318
+
166
319
  return {
167
320
  element,
168
321
  unmount(): void {
322
+ cancelPendingPortal(element)
169
323
  if (element.parentNode) {
170
324
  element.parentNode.removeChild(element)
171
325
  }
@@ -0,0 +1,47 @@
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
+ /**
11
+ * Run `update` now, re-run it on every scroll (capture phase, so a
12
+ * nested scroll container counts too) and on resize, and return the
13
+ * dispose that detaches both listeners.
14
+ *
15
+ * The dispose re-runs `update` ONCE, synchronously, before detaching —
16
+ * that final sample is the whole point of this helper. `scroll` events
17
+ * are coalesced per rendering frame and report the scroll position at
18
+ * dispatch time, not at scroll time. A programmatic scroll that landed
19
+ * in the current frame (a `focus()` on an offscreen item, a
20
+ * `scrollIntoView()`) has therefore not dispatched yet when a close
21
+ * runs in the same frame; the listener is gone by the time the event
22
+ * fires, and whatever position the listener would have written is lost.
23
+ * Without the final sample the closed element's inline position depends
24
+ * on whether a frame boundary happened to fall between that scroll and
25
+ * the close — measured as the `dropdown-menu` idempotence oracle
26
+ * landing on `top: -580px` / `-606px` / `33px` for the same action
27
+ * sequence. Sampling once at dispose makes the closed position a
28
+ * function of the geometry at close time only.
29
+ *
30
+ * (An `overflow: hidden` scroll lock does not narrow this window: it
31
+ * blocks user gestures, never programmatic scrolling, on `html` and
32
+ * `body` alike — verified in Chromium against the fixture-hydrate host.)
33
+ *
34
+ * @param update - Positions the element from current geometry.
35
+ * @returns Dispose: re-runs `update` once, then detaches the listeners.
36
+ */
37
+ export function trackPosition(update: () => void): () => void {
38
+ update()
39
+ const onChange = () => update()
40
+ window.addEventListener('scroll', onChange, true)
41
+ window.addEventListener('resize', onChange)
42
+ return () => {
43
+ window.removeEventListener('scroll', onChange, true)
44
+ window.removeEventListener('resize', onChange)
45
+ update()
46
+ }
47
+ }
package/src/shims.ts CHANGED
@@ -52,3 +52,7 @@ export function findSiblingSlot(
52
52
  export function cleanupPortalPlaceholder(_portalId: string): void {
53
53
  return browserOnly('cleanupPortalPlaceholder')
54
54
  }
55
+
56
+ export function trackPosition(_update: () => void): () => void {
57
+ return browserOnly('trackPosition')
58
+ }