@hanzogui/use-element-layout 2.0.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.
package/src/index.tsx ADDED
@@ -0,0 +1,597 @@
1
+ import { useIsomorphicLayoutEffect } from '@hanzogui/constants'
2
+ import { createContext, useContext, useId, type ReactNode, type RefObject } from 'react'
3
+
4
+ const LayoutHandlers = new WeakMap<HTMLElement, Function>()
5
+ const LayoutDisableKey = new WeakMap<HTMLElement, string>()
6
+ const Nodes = new Set<HTMLElement>()
7
+ const IntersectionState = new WeakMap<HTMLElement, boolean>()
8
+
9
+ // feature flag to enable pre-transform dimension reporting (matches RN behavior)
10
+ // can be set via env var at build time or runtime global for testing
11
+ // see: https://github.com/hanzoai/gui/pull/2329
12
+ const usePretransformDimensions = () =>
13
+ (globalThis as any).__HANZO_GUI_ONLAYOUT_PRETRANSFORM === true ||
14
+ process.env.HANZO_GUI_ONLAYOUT_PRETRANSFORM === '1'
15
+
16
+ let _debugLayout: boolean | undefined
17
+
18
+ function isDebugLayout() {
19
+ if (_debugLayout === undefined) {
20
+ _debugLayout =
21
+ typeof window !== 'undefined' &&
22
+ new URLSearchParams(window.location.search).has('__tamaDebugLayout')
23
+ }
24
+ return _debugLayout
25
+ }
26
+
27
+ // separating to avoid all re-rendering
28
+ const DisableLayoutContextValues: Record<string, boolean> = {}
29
+ const DisableLayoutContextKey = createContext<string>('')
30
+
31
+ const ENABLE =
32
+ process.env.HANZO_GUI_TARGET === 'web' && typeof IntersectionObserver !== 'undefined'
33
+
34
+ // internal testing - advanced helper to turn off layout measurement for extra performance
35
+ // TODO document!
36
+ // TODO could add frame skip control here
37
+ export const LayoutMeasurementController = ({
38
+ disable,
39
+ children,
40
+ }: {
41
+ disable: boolean
42
+ children: ReactNode
43
+ }): ReactNode => {
44
+ const id = useId()
45
+
46
+ useIsomorphicLayoutEffect(() => {
47
+ DisableLayoutContextValues[id] = disable
48
+ }, [disable, id])
49
+
50
+ return (
51
+ <DisableLayoutContextKey.Provider value={id}>
52
+ {children}
53
+ </DisableLayoutContextKey.Provider>
54
+ )
55
+ }
56
+
57
+ // Single persistent IntersectionObserver for visibility tracking
58
+ let globalIntersectionObserver: IntersectionObserver | null = null
59
+
60
+ type GuiComponentStatePartial = {
61
+ host?: any
62
+ }
63
+
64
+ type LayoutMeasurementStrategy = 'off' | 'sync' | 'async'
65
+
66
+ let strategy: LayoutMeasurementStrategy = 'async'
67
+
68
+ export function setOnLayoutStrategy(state: LayoutMeasurementStrategy): void {
69
+ strategy = state
70
+ }
71
+
72
+ export type LayoutValue = {
73
+ x: number
74
+ y: number
75
+ width: number
76
+ height: number
77
+ pageX: number
78
+ pageY: number
79
+ }
80
+
81
+ export type LayoutEvent = {
82
+ nativeEvent: {
83
+ layout: LayoutValue
84
+ target: any
85
+ }
86
+ timeStamp: number
87
+ }
88
+
89
+ const NodeRectCache = new WeakMap<HTMLElement, DOMRect>()
90
+
91
+ // prevent thrashing during first hydration (somewhat, streaming gets trickier)
92
+ let avoidUpdates = true
93
+ const queuedUpdates = new Map<HTMLElement, Function>()
94
+
95
+ export function enable(): void {
96
+ if (avoidUpdates) {
97
+ avoidUpdates = false
98
+ if (queuedUpdates) {
99
+ queuedUpdates.forEach((cb) => cb())
100
+ queuedUpdates.clear()
101
+ }
102
+ }
103
+ }
104
+
105
+ function startGlobalObservers() {
106
+ if (!ENABLE || globalIntersectionObserver) return
107
+
108
+ globalIntersectionObserver = new IntersectionObserver(
109
+ (entries) => {
110
+ for (let i = 0; i < entries.length; i++) {
111
+ const entry = entries[i]
112
+ const node = entry.target as HTMLElement
113
+ if (IntersectionState.get(node) !== entry.isIntersecting) {
114
+ IntersectionState.set(node, entry.isIntersecting)
115
+ }
116
+ }
117
+ },
118
+ {
119
+ threshold: 0,
120
+ }
121
+ )
122
+ }
123
+
124
+ // optimization: inline rect comparison to avoid function call overhead on hot path
125
+ function rectsEqual(a: DOMRectReadOnly, b: DOMRectReadOnly): boolean {
126
+ return a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height
127
+ }
128
+
129
+ if (ENABLE) {
130
+ const BoundingRects = new WeakMap<Element, DOMRectReadOnly>()
131
+
132
+ // optimization: persistent IO for rect fetching, reused across cycles
133
+ let rectFetchObserver: IntersectionObserver | null = null
134
+ let rectFetchResolve: ((value: boolean) => void) | null = null
135
+ let rectFetchStartTime = 0
136
+ let lastCallbackDelay = 0
137
+
138
+ function ensureRectFetchObserver() {
139
+ if (rectFetchObserver) return rectFetchObserver
140
+
141
+ rectFetchObserver = new IntersectionObserver(
142
+ (entries) => {
143
+ lastCallbackDelay = Math.round(performance.now() - rectFetchStartTime)
144
+
145
+ // store all rects
146
+ for (let i = 0; i < entries.length; i++) {
147
+ BoundingRects.set(entries[i].target, entries[i].boundingClientRect)
148
+ }
149
+
150
+ if (
151
+ process.env.NODE_ENV === 'development' &&
152
+ isDebugLayout() &&
153
+ lastCallbackDelay > 50
154
+ ) {
155
+ console.warn(
156
+ '[onLayout-io-delay]',
157
+ lastCallbackDelay + 'ms',
158
+ entries.length,
159
+ 'entries'
160
+ )
161
+ }
162
+
163
+ if (rectFetchResolve) {
164
+ rectFetchResolve(true)
165
+ rectFetchResolve = null
166
+ }
167
+ },
168
+ {
169
+ threshold: 0,
170
+ }
171
+ )
172
+
173
+ return rectFetchObserver
174
+ }
175
+
176
+ async function updateLayoutIfChanged(node: HTMLElement) {
177
+ const onLayout = LayoutHandlers.get(node)
178
+ if (typeof onLayout !== 'function') return
179
+
180
+ const parentNode = node.parentElement
181
+ if (!parentNode) return
182
+
183
+ let nodeRect: DOMRectReadOnly | undefined
184
+ let parentRect: DOMRectReadOnly | undefined
185
+
186
+ // respect the strategy contract
187
+ if (strategy === 'async') {
188
+ nodeRect = BoundingRects.get(node)
189
+ parentRect = BoundingRects.get(parentNode)
190
+
191
+ if (!nodeRect || !parentRect) {
192
+ return
193
+ }
194
+ } else {
195
+ nodeRect = node.getBoundingClientRect()
196
+ parentRect = parentNode.getBoundingClientRect()
197
+ }
198
+
199
+ const cachedRect = NodeRectCache.get(node)
200
+ const cachedParentRect = NodeRectCache.get(parentNode)
201
+
202
+ // optimization: inline comparison instead of isEqualShallow
203
+ const nodeChanged = !cachedRect || !rectsEqual(cachedRect, nodeRect)
204
+ const parentChanged = !cachedParentRect || !rectsEqual(cachedParentRect, parentRect)
205
+
206
+ if (nodeChanged || parentChanged) {
207
+ NodeRectCache.set(node, nodeRect as DOMRect)
208
+ NodeRectCache.set(parentNode, parentRect as DOMRect)
209
+
210
+ const event = getElementLayoutEvent(nodeRect, parentRect, node)
211
+
212
+ if (process.env.NODE_ENV === 'development' && isDebugLayout()) {
213
+ console.log('[useElementLayout] change', {
214
+ tag: node.tagName,
215
+ id: node.id || undefined,
216
+ className: (node.className || '').slice(0, 60) || undefined,
217
+ layout: event.nativeEvent.layout,
218
+ first: !cachedRect,
219
+ })
220
+ }
221
+
222
+ if (avoidUpdates) {
223
+ queuedUpdates.set(node, () => onLayout(event))
224
+ } else {
225
+ onLayout(event)
226
+ }
227
+ }
228
+ }
229
+
230
+ const rAF =
231
+ typeof requestAnimationFrame !== 'undefined' ? requestAnimationFrame : undefined
232
+
233
+ // adaptive frame skipping with backoff
234
+ const userSkipVal = process.env.HANZO_GUI_LAYOUT_FRAME_SKIP
235
+ const BASE_SKIP_FRAMES = userSkipVal ? +userSkipVal : 10
236
+ const MAX_SKIP_FRAMES = 20
237
+ let skipFrames = BASE_SKIP_FRAMES
238
+ let frameCount = 0
239
+
240
+ async function layoutOnAnimationFrame() {
241
+ // skip frames based on adaptive rate
242
+ if (frameCount++ % skipFrames !== 0) {
243
+ rAF ? rAF(layoutOnAnimationFrame) : setTimeout(layoutOnAnimationFrame, 16)
244
+ return
245
+ }
246
+
247
+ // reset frame count to avoid overflow
248
+ if (frameCount >= Number.MAX_SAFE_INTEGER) {
249
+ frameCount = 0
250
+ }
251
+
252
+ if (strategy !== 'off') {
253
+ const visibleNodes: HTMLElement[] = []
254
+ // optimization: deduplicate parent observations
255
+ const parentsToObserve = new Set<HTMLElement>()
256
+
257
+ // collect visible nodes and their unique parents
258
+ for (const node of Nodes) {
259
+ const parentElement = node.parentElement
260
+ if (!(parentElement instanceof HTMLElement)) {
261
+ cleanupNode(node)
262
+ continue
263
+ }
264
+ const disableKey = LayoutDisableKey.get(node)
265
+ if (disableKey && DisableLayoutContextValues[disableKey] === true) continue
266
+ if (IntersectionState.get(node) === false) continue
267
+
268
+ visibleNodes.push(node)
269
+ parentsToObserve.add(parentElement)
270
+ }
271
+
272
+ if (visibleNodes.length > 0) {
273
+ const io = ensureRectFetchObserver()
274
+ rectFetchStartTime = performance.now()
275
+
276
+ // observe all nodes
277
+ for (let i = 0; i < visibleNodes.length; i++) {
278
+ io.observe(visibleNodes[i])
279
+ }
280
+ // optimization: observe unique parents only (not N times for N children)
281
+ for (const parent of parentsToObserve) {
282
+ io.observe(parent)
283
+ }
284
+
285
+ // wait for callback
286
+ await new Promise<boolean>((res) => {
287
+ rectFetchResolve = res
288
+ })
289
+
290
+ // unobserve all to reset for next cycle
291
+ for (let i = 0; i < visibleNodes.length; i++) {
292
+ io.unobserve(visibleNodes[i])
293
+ }
294
+ for (const parent of parentsToObserve) {
295
+ io.unobserve(parent)
296
+ }
297
+
298
+ // adaptive backoff: if IO was slow, skip more frames next cycle
299
+ if (lastCallbackDelay > 50) {
300
+ skipFrames = Math.min(skipFrames + 2, MAX_SKIP_FRAMES)
301
+ } else if (lastCallbackDelay < 20) {
302
+ // recover back to base rate when things are fast
303
+ skipFrames = Math.max(skipFrames - 1, BASE_SKIP_FRAMES)
304
+ }
305
+
306
+ // process updates
307
+ for (let i = 0; i < visibleNodes.length; i++) {
308
+ updateLayoutIfChanged(visibleNodes[i])
309
+ }
310
+ }
311
+ }
312
+
313
+ // schedule next frame
314
+ rAF ? rAF(layoutOnAnimationFrame) : setTimeout(layoutOnAnimationFrame, 16)
315
+ }
316
+
317
+ layoutOnAnimationFrame()
318
+ }
319
+
320
+ export const getElementLayoutEvent = (
321
+ nodeRect: DOMRectReadOnly,
322
+ parentRect: DOMRectReadOnly,
323
+ node?: HTMLElement
324
+ ): LayoutEvent => {
325
+ return {
326
+ nativeEvent: {
327
+ layout: getRelativeDimensions(nodeRect, parentRect, node),
328
+ target: nodeRect,
329
+ },
330
+ timeStamp: Date.now(),
331
+ }
332
+ }
333
+
334
+ /**
335
+ * get pre-transform dimensions for a node.
336
+ * uses offsetWidth/offsetHeight which report CSS layout dimensions
337
+ * unaffected by transforms - this matches React Native's onLayout behavior.
338
+ *
339
+ * see: https://github.com/hanzoai/gui/pull/2329
340
+ */
341
+ const getPreTransformDimensions = (
342
+ node: HTMLElement
343
+ ): { width: number; height: number } => {
344
+ return {
345
+ width: node.offsetWidth,
346
+ height: node.offsetHeight,
347
+ }
348
+ }
349
+
350
+ const getRelativeDimensions = (
351
+ a: DOMRectReadOnly,
352
+ b: DOMRectReadOnly,
353
+ aNode?: HTMLElement
354
+ ) => {
355
+ const { left, top } = a
356
+ const x = left - b.left
357
+ const y = top - b.top
358
+
359
+ // get pre-transform dimensions when flag is enabled and node is available
360
+ const { width, height } =
361
+ usePretransformDimensions() && aNode
362
+ ? getPreTransformDimensions(aNode)
363
+ : { width: a.width, height: a.height }
364
+
365
+ return { x, y, width, height, pageX: a.left, pageY: a.top }
366
+ }
367
+
368
+ // register an arbitrary DOM element into the measurement loop without React lifecycle
369
+ export function registerLayoutNode(
370
+ node: HTMLElement,
371
+ onChange: () => void,
372
+ disableKey?: string
373
+ ): () => void {
374
+ Nodes.add(node)
375
+ LayoutHandlers.set(node, onChange)
376
+ if (disableKey) LayoutDisableKey.set(node, disableKey)
377
+ startGlobalObservers()
378
+ if (globalIntersectionObserver) {
379
+ globalIntersectionObserver.observe(node)
380
+ IntersectionState.set(node, true)
381
+ }
382
+ return () => cleanupNode(node)
383
+ }
384
+
385
+ function cleanupNode(node: HTMLElement) {
386
+ Nodes.delete(node)
387
+ LayoutHandlers.delete(node)
388
+ LayoutDisableKey.delete(node)
389
+ NodeRectCache.delete(node)
390
+ IntersectionState.delete(node)
391
+ if (globalIntersectionObserver) {
392
+ globalIntersectionObserver.unobserve(node)
393
+ }
394
+ }
395
+
396
+ const PrevHostNode = new WeakMap<object, HTMLElement | undefined>()
397
+
398
+ export function useElementLayout(
399
+ ref: RefObject<GuiComponentStatePartial>,
400
+ onLayout?: ((e: LayoutEvent) => void) | null
401
+ ): void {
402
+ const disableKey = useContext(DisableLayoutContextKey)
403
+
404
+ // keep handlers up to date so polling always calls the latest callback
405
+ const node = ensureWebElement(ref.current?.host)
406
+ if (node && onLayout) {
407
+ LayoutHandlers.set(node, onLayout)
408
+ LayoutDisableKey.set(node, disableKey)
409
+ }
410
+
411
+ // detect host swaps after commit and fire immediate sync layout
412
+ useIsomorphicLayoutEffect(() => {
413
+ if (!onLayout) return
414
+ const nextNode = ensureWebElement(ref.current?.host)
415
+ const prevNode = PrevHostNode.get(ref)
416
+ if (nextNode === prevNode) return
417
+
418
+ if (prevNode) cleanupNode(prevNode)
419
+ PrevHostNode.set(ref, nextNode)
420
+ if (!nextNode) return
421
+
422
+ Nodes.add(nextNode)
423
+ startGlobalObservers()
424
+ if (globalIntersectionObserver) {
425
+ globalIntersectionObserver.observe(nextNode)
426
+ IntersectionState.set(nextNode, true)
427
+ }
428
+
429
+ const handler = LayoutHandlers.get(nextNode)
430
+ if (typeof handler !== 'function') return
431
+ const parentNode = nextNode.parentElement
432
+ if (!parentNode) return
433
+
434
+ const nodeRect = nextNode.getBoundingClientRect()
435
+ const parentRect = parentNode.getBoundingClientRect()
436
+ NodeRectCache.set(nextNode, nodeRect)
437
+ NodeRectCache.set(parentNode, parentRect)
438
+ handler(getElementLayoutEvent(nodeRect, parentRect, nextNode))
439
+ })
440
+
441
+ useIsomorphicLayoutEffect(() => {
442
+ if (!onLayout) return
443
+ const node = ref.current?.host
444
+ if (!node) return
445
+
446
+ Nodes.add(node)
447
+
448
+ startGlobalObservers()
449
+ if (globalIntersectionObserver) {
450
+ globalIntersectionObserver.observe(node)
451
+ IntersectionState.set(node, true)
452
+ }
453
+
454
+ if (process.env.NODE_ENV === 'development' && isDebugLayout()) {
455
+ console.log('[useElementLayout] register', {
456
+ tag: node.tagName,
457
+ id: node.id || undefined,
458
+ className: (node.className || '').slice(0, 60) || undefined,
459
+ totalNodes: Nodes.size,
460
+ })
461
+ }
462
+
463
+ // always do one immediate sync layout event for accuracy
464
+ const parentNode = node.parentNode as HTMLElement | null
465
+ if (parentNode) {
466
+ onLayout(
467
+ getElementLayoutEvent(
468
+ node.getBoundingClientRect(),
469
+ parentNode.getBoundingClientRect(),
470
+ node
471
+ )
472
+ )
473
+ }
474
+
475
+ return () => {
476
+ cleanupNode(node)
477
+
478
+ // also clean up any node from a mid-lifecycle host swap
479
+ const swappedNode = PrevHostNode.get(ref)
480
+ if (swappedNode && swappedNode !== node) {
481
+ cleanupNode(swappedNode)
482
+ }
483
+ PrevHostNode.delete(ref)
484
+ }
485
+ }, [ref, !!onLayout])
486
+ }
487
+
488
+ function ensureWebElement<X>(x: X): HTMLElement | undefined {
489
+ if (typeof HTMLElement === 'undefined') {
490
+ return undefined
491
+ }
492
+ return x instanceof HTMLElement ? x : undefined
493
+ }
494
+
495
+ export const getBoundingClientRectAsync = (
496
+ node: HTMLElement | null
497
+ ): Promise<DOMRectReadOnly | false> => {
498
+ return new Promise<DOMRectReadOnly | false>((res) => {
499
+ if (!node || node.nodeType !== 1) return res(false)
500
+
501
+ const io = new IntersectionObserver(
502
+ (entries) => {
503
+ io.disconnect()
504
+ return res(entries[0].boundingClientRect)
505
+ },
506
+ {
507
+ threshold: 0,
508
+ }
509
+ )
510
+ io.observe(node)
511
+ })
512
+ }
513
+
514
+ export const measureNode = async (
515
+ node: HTMLElement,
516
+ relativeTo?: HTMLElement | null
517
+ ): Promise<null | LayoutValue> => {
518
+ const relativeNode = relativeTo || node?.parentElement
519
+ if (relativeNode instanceof HTMLElement) {
520
+ const [nodeDim, relativeNodeDim] = await Promise.all([
521
+ getBoundingClientRectAsync(node),
522
+ getBoundingClientRectAsync(relativeNode),
523
+ ])
524
+ if (relativeNodeDim && nodeDim) {
525
+ return getRelativeDimensions(nodeDim, relativeNodeDim, node)
526
+ }
527
+ }
528
+ return null
529
+ }
530
+
531
+ type MeasureInWindowCb = (x: number, y: number, width: number, height: number) => void
532
+
533
+ type MeasureCb = (
534
+ x: number,
535
+ y: number,
536
+ width: number,
537
+ height: number,
538
+ pageX: number,
539
+ pageY: number
540
+ ) => void
541
+
542
+ export const measure = async (
543
+ node: HTMLElement,
544
+ callback: MeasureCb
545
+ ): Promise<LayoutValue | null> => {
546
+ const out = await measureNode(
547
+ node,
548
+ node.parentNode instanceof HTMLElement ? node.parentNode : null
549
+ )
550
+ if (out) {
551
+ callback?.(out.x, out.y, out.width, out.height, out.pageX, out.pageY)
552
+ }
553
+ return out
554
+ }
555
+
556
+ export function createMeasure(
557
+ node: HTMLElement
558
+ ): (callback: MeasureCb) => Promise<LayoutValue | null> {
559
+ return (callback) => measure(node, callback)
560
+ }
561
+
562
+ type WindowLayout = { pageX: number; pageY: number; width: number; height: number }
563
+
564
+ export const measureInWindow = async (
565
+ node: HTMLElement,
566
+ callback: MeasureInWindowCb
567
+ ): Promise<WindowLayout | null> => {
568
+ const out = await measureNode(node, null)
569
+ if (out) {
570
+ callback?.(out.pageX, out.pageY, out.width, out.height)
571
+ }
572
+ return out
573
+ }
574
+
575
+ export const createMeasureInWindow = (
576
+ node: HTMLElement
577
+ ): ((callback: MeasureInWindowCb) => Promise<WindowLayout | null>) => {
578
+ return (callback) => measureInWindow(node, callback)
579
+ }
580
+
581
+ export const measureLayout = async (
582
+ node: HTMLElement,
583
+ relativeNode: HTMLElement,
584
+ callback: MeasureCb
585
+ ): Promise<LayoutValue | null> => {
586
+ const out = await measureNode(node, relativeNode)
587
+ if (out) {
588
+ callback?.(out.x, out.y, out.width, out.height, out.pageX, out.pageY)
589
+ }
590
+ return out
591
+ }
592
+
593
+ export function createMeasureLayout(
594
+ node: HTMLElement
595
+ ): (relativeTo: HTMLElement, callback: MeasureCb) => Promise<LayoutValue | null> {
596
+ return (relativeTo, callback) => measureLayout(node, relativeTo, callback)
597
+ }
@@ -0,0 +1,48 @@
1
+ import { type ReactNode, type RefObject } from "react";
2
+ export declare const LayoutMeasurementController: ({ disable, children }: {
3
+ disable: boolean;
4
+ children: ReactNode;
5
+ }) => ReactNode;
6
+ type GuiComponentStatePartial = {
7
+ host?: any;
8
+ };
9
+ type LayoutMeasurementStrategy = "off" | "sync" | "async";
10
+ export declare function setOnLayoutStrategy(state: LayoutMeasurementStrategy): void;
11
+ export type LayoutValue = {
12
+ x: number;
13
+ y: number;
14
+ width: number;
15
+ height: number;
16
+ pageX: number;
17
+ pageY: number;
18
+ };
19
+ export type LayoutEvent = {
20
+ nativeEvent: {
21
+ layout: LayoutValue;
22
+ target: any;
23
+ };
24
+ timeStamp: number;
25
+ };
26
+ export declare function enable(): void;
27
+ export declare const getElementLayoutEvent: (nodeRect: DOMRectReadOnly, parentRect: DOMRectReadOnly, node?: HTMLElement) => LayoutEvent;
28
+ export declare function registerLayoutNode(node: HTMLElement, onChange: () => void, disableKey?: string): () => void;
29
+ export declare function useElementLayout(ref: RefObject<GuiComponentStatePartial>, onLayout?: ((e: LayoutEvent) => void) | null): void;
30
+ export declare const getBoundingClientRectAsync: (node: HTMLElement | null) => Promise<DOMRectReadOnly | false>;
31
+ export declare const measureNode: (node: HTMLElement, relativeTo?: HTMLElement | null) => Promise<null | LayoutValue>;
32
+ type MeasureInWindowCb = (x: number, y: number, width: number, height: number) => void;
33
+ type MeasureCb = (x: number, y: number, width: number, height: number, pageX: number, pageY: number) => void;
34
+ export declare const measure: (node: HTMLElement, callback: MeasureCb) => Promise<LayoutValue | null>;
35
+ export declare function createMeasure(node: HTMLElement): (callback: MeasureCb) => Promise<LayoutValue | null>;
36
+ type WindowLayout = {
37
+ pageX: number;
38
+ pageY: number;
39
+ width: number;
40
+ height: number;
41
+ };
42
+ export declare const measureInWindow: (node: HTMLElement, callback: MeasureInWindowCb) => Promise<WindowLayout | null>;
43
+ export declare const createMeasureInWindow: (node: HTMLElement) => ((callback: MeasureInWindowCb) => Promise<WindowLayout | null>);
44
+ export declare const measureLayout: (node: HTMLElement, relativeNode: HTMLElement, callback: MeasureCb) => Promise<LayoutValue | null>;
45
+ export declare function createMeasureLayout(node: HTMLElement): (relativeTo: HTMLElement, callback: MeasureCb) => Promise<LayoutValue | null>;
46
+ export {};
47
+
48
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,11 @@
1
+ {
2
+ "mappings": "AACA,cAAgD,gBAAgB,iBAAiB;AAmCjF,OAAO,cAAM,8BAA+B,EAC1C,SACA,YACC;CACD;CACA,UAAU;MACR;KAiBC,2BAA2B;CAC9B;;KAGG,4BAA4B,QAAQ,SAAS;AAIlD,OAAO,iBAAS,oBAAoB,OAAO;AAI3C,YAAY,cAAc;CACxB;CACA;CACA;CACA;CACA;CACA;;AAGF,YAAY,cAAc;CACxB,aAAa;EACX,QAAQ;EACR;;CAEF;;AASF,OAAO,iBAAS;AAiOhB,OAAO,cAAM,wBACX,UAAU,iBACV,YAAY,iBACZ,OAAO,gBACN;AA6CH,OAAO,iBAAS,mBACd,MAAM,aACN,sBACA;AA0BF,OAAO,iBAAS,iBACd,KAAK,UAAU,2BACf,aAAa,GAAG;AA+FlB,OAAO,cAAM,6BACX,MAAM,uBACL,QAAQ,kBAAkB;AAiB7B,OAAO,cAAM,cACX,MAAM,aACN,aAAa,uBACZ,eAAe;KAcb,qBAAqB,WAAW,WAAW,eAAe;KAE1D,aACH,WACA,WACA,eACA,gBACA,eACA;AAGF,OAAO,cAAM,UACX,MAAM,aACN,UAAU,cACT,QAAQ;AAWX,OAAO,iBAAS,cACd,MAAM,eACJ,UAAU,cAAc,QAAQ;KAI/B,eAAe;CAAE;CAAe;CAAe;CAAe;;AAEnE,OAAO,cAAM,kBACX,MAAM,aACN,UAAU,sBACT,QAAQ;AAQX,OAAO,cAAM,wBACX,MAAM,kBACH,UAAU,sBAAsB,QAAQ;AAI7C,OAAO,cAAM,gBACX,MAAM,aACN,cAAc,aACd,UAAU,cACT,QAAQ;AAQX,OAAO,iBAAS,oBACd,MAAM,eACJ,YAAY,aAAa,UAAU,cAAc,QAAQ",
3
+ "names": [],
4
+ "sources": [
5
+ "src/index.tsx"
6
+ ],
7
+ "version": 3,
8
+ "sourcesContent": [
9
+ "import { useIsomorphicLayoutEffect } from '@hanzogui/constants'\nimport { createContext, useContext, useId, type ReactNode, type RefObject } from 'react'\n\nconst LayoutHandlers = new WeakMap<HTMLElement, Function>()\nconst LayoutDisableKey = new WeakMap<HTMLElement, string>()\nconst Nodes = new Set<HTMLElement>()\nconst IntersectionState = new WeakMap<HTMLElement, boolean>()\n\n// feature flag to enable pre-transform dimension reporting (matches RN behavior)\n// can be set via env var at build time or runtime global for testing\n// see: https://github.com/hanzoai/gui/pull/2329\nconst usePretransformDimensions = () =>\n (globalThis as any).__HANZO_GUI_ONLAYOUT_PRETRANSFORM === true ||\n process.env.HANZO_GUI_ONLAYOUT_PRETRANSFORM === '1'\n\nlet _debugLayout: boolean | undefined\n\nfunction isDebugLayout() {\n if (_debugLayout === undefined) {\n _debugLayout =\n typeof window !== 'undefined' &&\n new URLSearchParams(window.location.search).has('__tamaDebugLayout')\n }\n return _debugLayout\n}\n\n// separating to avoid all re-rendering\nconst DisableLayoutContextValues: Record<string, boolean> = {}\nconst DisableLayoutContextKey = createContext<string>('')\n\nconst ENABLE =\n process.env.HANZO_GUI_TARGET === 'web' && typeof IntersectionObserver !== 'undefined'\n\n// internal testing - advanced helper to turn off layout measurement for extra performance\n// TODO document!\n// TODO could add frame skip control here\nexport const LayoutMeasurementController = ({\n disable,\n children,\n}: {\n disable: boolean\n children: ReactNode\n}): ReactNode => {\n const id = useId()\n\n useIsomorphicLayoutEffect(() => {\n DisableLayoutContextValues[id] = disable\n }, [disable, id])\n\n return (\n <DisableLayoutContextKey.Provider value={id}>\n {children}\n </DisableLayoutContextKey.Provider>\n )\n}\n\n// Single persistent IntersectionObserver for visibility tracking\nlet globalIntersectionObserver: IntersectionObserver | null = null\n\ntype GuiComponentStatePartial = {\n host?: any\n}\n\ntype LayoutMeasurementStrategy = 'off' | 'sync' | 'async'\n\nlet strategy: LayoutMeasurementStrategy = 'async'\n\nexport function setOnLayoutStrategy(state: LayoutMeasurementStrategy): void {\n strategy = state\n}\n\nexport type LayoutValue = {\n x: number\n y: number\n width: number\n height: number\n pageX: number\n pageY: number\n}\n\nexport type LayoutEvent = {\n nativeEvent: {\n layout: LayoutValue\n target: any\n }\n timeStamp: number\n}\n\nconst NodeRectCache = new WeakMap<HTMLElement, DOMRect>()\n\n// prevent thrashing during first hydration (somewhat, streaming gets trickier)\nlet avoidUpdates = true\nconst queuedUpdates = new Map<HTMLElement, Function>()\n\nexport function enable(): void {\n if (avoidUpdates) {\n avoidUpdates = false\n if (queuedUpdates) {\n queuedUpdates.forEach((cb) => cb())\n queuedUpdates.clear()\n }\n }\n}\n\nfunction startGlobalObservers() {\n if (!ENABLE || globalIntersectionObserver) return\n\n globalIntersectionObserver = new IntersectionObserver(\n (entries) => {\n for (let i = 0; i < entries.length; i++) {\n const entry = entries[i]\n const node = entry.target as HTMLElement\n if (IntersectionState.get(node) !== entry.isIntersecting) {\n IntersectionState.set(node, entry.isIntersecting)\n }\n }\n },\n {\n threshold: 0,\n }\n )\n}\n\n// optimization: inline rect comparison to avoid function call overhead on hot path\nfunction rectsEqual(a: DOMRectReadOnly, b: DOMRectReadOnly): boolean {\n return a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height\n}\n\nif (ENABLE) {\n const BoundingRects = new WeakMap<Element, DOMRectReadOnly>()\n\n // optimization: persistent IO for rect fetching, reused across cycles\n let rectFetchObserver: IntersectionObserver | null = null\n let rectFetchResolve: ((value: boolean) => void) | null = null\n let rectFetchStartTime = 0\n let lastCallbackDelay = 0\n\n function ensureRectFetchObserver() {\n if (rectFetchObserver) return rectFetchObserver\n\n rectFetchObserver = new IntersectionObserver(\n (entries) => {\n lastCallbackDelay = Math.round(performance.now() - rectFetchStartTime)\n\n // store all rects\n for (let i = 0; i < entries.length; i++) {\n BoundingRects.set(entries[i].target, entries[i].boundingClientRect)\n }\n\n if (\n process.env.NODE_ENV === 'development' &&\n isDebugLayout() &&\n lastCallbackDelay > 50\n ) {\n console.warn(\n '[onLayout-io-delay]',\n lastCallbackDelay + 'ms',\n entries.length,\n 'entries'\n )\n }\n\n if (rectFetchResolve) {\n rectFetchResolve(true)\n rectFetchResolve = null\n }\n },\n {\n threshold: 0,\n }\n )\n\n return rectFetchObserver\n }\n\n async function updateLayoutIfChanged(node: HTMLElement) {\n const onLayout = LayoutHandlers.get(node)\n if (typeof onLayout !== 'function') return\n\n const parentNode = node.parentElement\n if (!parentNode) return\n\n let nodeRect: DOMRectReadOnly | undefined\n let parentRect: DOMRectReadOnly | undefined\n\n // respect the strategy contract\n if (strategy === 'async') {\n nodeRect = BoundingRects.get(node)\n parentRect = BoundingRects.get(parentNode)\n\n if (!nodeRect || !parentRect) {\n return\n }\n } else {\n nodeRect = node.getBoundingClientRect()\n parentRect = parentNode.getBoundingClientRect()\n }\n\n const cachedRect = NodeRectCache.get(node)\n const cachedParentRect = NodeRectCache.get(parentNode)\n\n // optimization: inline comparison instead of isEqualShallow\n const nodeChanged = !cachedRect || !rectsEqual(cachedRect, nodeRect)\n const parentChanged = !cachedParentRect || !rectsEqual(cachedParentRect, parentRect)\n\n if (nodeChanged || parentChanged) {\n NodeRectCache.set(node, nodeRect as DOMRect)\n NodeRectCache.set(parentNode, parentRect as DOMRect)\n\n const event = getElementLayoutEvent(nodeRect, parentRect, node)\n\n if (process.env.NODE_ENV === 'development' && isDebugLayout()) {\n console.log('[useElementLayout] change', {\n tag: node.tagName,\n id: node.id || undefined,\n className: (node.className || '').slice(0, 60) || undefined,\n layout: event.nativeEvent.layout,\n first: !cachedRect,\n })\n }\n\n if (avoidUpdates) {\n queuedUpdates.set(node, () => onLayout(event))\n } else {\n onLayout(event)\n }\n }\n }\n\n const rAF =\n typeof requestAnimationFrame !== 'undefined' ? requestAnimationFrame : undefined\n\n // adaptive frame skipping with backoff\n const userSkipVal = process.env.HANZO_GUI_LAYOUT_FRAME_SKIP\n const BASE_SKIP_FRAMES = userSkipVal ? +userSkipVal : 10\n const MAX_SKIP_FRAMES = 20\n let skipFrames = BASE_SKIP_FRAMES\n let frameCount = 0\n\n async function layoutOnAnimationFrame() {\n // skip frames based on adaptive rate\n if (frameCount++ % skipFrames !== 0) {\n rAF ? rAF(layoutOnAnimationFrame) : setTimeout(layoutOnAnimationFrame, 16)\n return\n }\n\n // reset frame count to avoid overflow\n if (frameCount >= Number.MAX_SAFE_INTEGER) {\n frameCount = 0\n }\n\n if (strategy !== 'off') {\n const visibleNodes: HTMLElement[] = []\n // optimization: deduplicate parent observations\n const parentsToObserve = new Set<HTMLElement>()\n\n // collect visible nodes and their unique parents\n for (const node of Nodes) {\n const parentElement = node.parentElement\n if (!(parentElement instanceof HTMLElement)) {\n cleanupNode(node)\n continue\n }\n const disableKey = LayoutDisableKey.get(node)\n if (disableKey && DisableLayoutContextValues[disableKey] === true) continue\n if (IntersectionState.get(node) === false) continue\n\n visibleNodes.push(node)\n parentsToObserve.add(parentElement)\n }\n\n if (visibleNodes.length > 0) {\n const io = ensureRectFetchObserver()\n rectFetchStartTime = performance.now()\n\n // observe all nodes\n for (let i = 0; i < visibleNodes.length; i++) {\n io.observe(visibleNodes[i])\n }\n // optimization: observe unique parents only (not N times for N children)\n for (const parent of parentsToObserve) {\n io.observe(parent)\n }\n\n // wait for callback\n await new Promise<boolean>((res) => {\n rectFetchResolve = res\n })\n\n // unobserve all to reset for next cycle\n for (let i = 0; i < visibleNodes.length; i++) {\n io.unobserve(visibleNodes[i])\n }\n for (const parent of parentsToObserve) {\n io.unobserve(parent)\n }\n\n // adaptive backoff: if IO was slow, skip more frames next cycle\n if (lastCallbackDelay > 50) {\n skipFrames = Math.min(skipFrames + 2, MAX_SKIP_FRAMES)\n } else if (lastCallbackDelay < 20) {\n // recover back to base rate when things are fast\n skipFrames = Math.max(skipFrames - 1, BASE_SKIP_FRAMES)\n }\n\n // process updates\n for (let i = 0; i < visibleNodes.length; i++) {\n updateLayoutIfChanged(visibleNodes[i])\n }\n }\n }\n\n // schedule next frame\n rAF ? rAF(layoutOnAnimationFrame) : setTimeout(layoutOnAnimationFrame, 16)\n }\n\n layoutOnAnimationFrame()\n}\n\nexport const getElementLayoutEvent = (\n nodeRect: DOMRectReadOnly,\n parentRect: DOMRectReadOnly,\n node?: HTMLElement\n): LayoutEvent => {\n return {\n nativeEvent: {\n layout: getRelativeDimensions(nodeRect, parentRect, node),\n target: nodeRect,\n },\n timeStamp: Date.now(),\n }\n}\n\n/**\n * get pre-transform dimensions for a node.\n * uses offsetWidth/offsetHeight which report CSS layout dimensions\n * unaffected by transforms - this matches React Native's onLayout behavior.\n *\n * see: https://github.com/hanzoai/gui/pull/2329\n */\nconst getPreTransformDimensions = (\n node: HTMLElement\n): { width: number; height: number } => {\n return {\n width: node.offsetWidth,\n height: node.offsetHeight,\n }\n}\n\nconst getRelativeDimensions = (\n a: DOMRectReadOnly,\n b: DOMRectReadOnly,\n aNode?: HTMLElement\n) => {\n const { left, top } = a\n const x = left - b.left\n const y = top - b.top\n\n // get pre-transform dimensions when flag is enabled and node is available\n const { width, height } =\n usePretransformDimensions() && aNode\n ? getPreTransformDimensions(aNode)\n : { width: a.width, height: a.height }\n\n return { x, y, width, height, pageX: a.left, pageY: a.top }\n}\n\n// register an arbitrary DOM element into the measurement loop without React lifecycle\nexport function registerLayoutNode(\n node: HTMLElement,\n onChange: () => void,\n disableKey?: string\n): () => void {\n Nodes.add(node)\n LayoutHandlers.set(node, onChange)\n if (disableKey) LayoutDisableKey.set(node, disableKey)\n startGlobalObservers()\n if (globalIntersectionObserver) {\n globalIntersectionObserver.observe(node)\n IntersectionState.set(node, true)\n }\n return () => cleanupNode(node)\n}\n\nfunction cleanupNode(node: HTMLElement) {\n Nodes.delete(node)\n LayoutHandlers.delete(node)\n LayoutDisableKey.delete(node)\n NodeRectCache.delete(node)\n IntersectionState.delete(node)\n if (globalIntersectionObserver) {\n globalIntersectionObserver.unobserve(node)\n }\n}\n\nconst PrevHostNode = new WeakMap<object, HTMLElement | undefined>()\n\nexport function useElementLayout(\n ref: RefObject<GuiComponentStatePartial>,\n onLayout?: ((e: LayoutEvent) => void) | null\n): void {\n const disableKey = useContext(DisableLayoutContextKey)\n\n // keep handlers up to date so polling always calls the latest callback\n const node = ensureWebElement(ref.current?.host)\n if (node && onLayout) {\n LayoutHandlers.set(node, onLayout)\n LayoutDisableKey.set(node, disableKey)\n }\n\n // detect host swaps after commit and fire immediate sync layout\n useIsomorphicLayoutEffect(() => {\n if (!onLayout) return\n const nextNode = ensureWebElement(ref.current?.host)\n const prevNode = PrevHostNode.get(ref)\n if (nextNode === prevNode) return\n\n if (prevNode) cleanupNode(prevNode)\n PrevHostNode.set(ref, nextNode)\n if (!nextNode) return\n\n Nodes.add(nextNode)\n startGlobalObservers()\n if (globalIntersectionObserver) {\n globalIntersectionObserver.observe(nextNode)\n IntersectionState.set(nextNode, true)\n }\n\n const handler = LayoutHandlers.get(nextNode)\n if (typeof handler !== 'function') return\n const parentNode = nextNode.parentElement\n if (!parentNode) return\n\n const nodeRect = nextNode.getBoundingClientRect()\n const parentRect = parentNode.getBoundingClientRect()\n NodeRectCache.set(nextNode, nodeRect)\n NodeRectCache.set(parentNode, parentRect)\n handler(getElementLayoutEvent(nodeRect, parentRect, nextNode))\n })\n\n useIsomorphicLayoutEffect(() => {\n if (!onLayout) return\n const node = ref.current?.host\n if (!node) return\n\n Nodes.add(node)\n\n startGlobalObservers()\n if (globalIntersectionObserver) {\n globalIntersectionObserver.observe(node)\n IntersectionState.set(node, true)\n }\n\n if (process.env.NODE_ENV === 'development' && isDebugLayout()) {\n console.log('[useElementLayout] register', {\n tag: node.tagName,\n id: node.id || undefined,\n className: (node.className || '').slice(0, 60) || undefined,\n totalNodes: Nodes.size,\n })\n }\n\n // always do one immediate sync layout event for accuracy\n const parentNode = node.parentNode as HTMLElement | null\n if (parentNode) {\n onLayout(\n getElementLayoutEvent(\n node.getBoundingClientRect(),\n parentNode.getBoundingClientRect(),\n node\n )\n )\n }\n\n return () => {\n cleanupNode(node)\n\n // also clean up any node from a mid-lifecycle host swap\n const swappedNode = PrevHostNode.get(ref)\n if (swappedNode && swappedNode !== node) {\n cleanupNode(swappedNode)\n }\n PrevHostNode.delete(ref)\n }\n }, [ref, !!onLayout])\n}\n\nfunction ensureWebElement<X>(x: X): HTMLElement | undefined {\n if (typeof HTMLElement === 'undefined') {\n return undefined\n }\n return x instanceof HTMLElement ? x : undefined\n}\n\nexport const getBoundingClientRectAsync = (\n node: HTMLElement | null\n): Promise<DOMRectReadOnly | false> => {\n return new Promise<DOMRectReadOnly | false>((res) => {\n if (!node || node.nodeType !== 1) return res(false)\n\n const io = new IntersectionObserver(\n (entries) => {\n io.disconnect()\n return res(entries[0].boundingClientRect)\n },\n {\n threshold: 0,\n }\n )\n io.observe(node)\n })\n}\n\nexport const measureNode = async (\n node: HTMLElement,\n relativeTo?: HTMLElement | null\n): Promise<null | LayoutValue> => {\n const relativeNode = relativeTo || node?.parentElement\n if (relativeNode instanceof HTMLElement) {\n const [nodeDim, relativeNodeDim] = await Promise.all([\n getBoundingClientRectAsync(node),\n getBoundingClientRectAsync(relativeNode),\n ])\n if (relativeNodeDim && nodeDim) {\n return getRelativeDimensions(nodeDim, relativeNodeDim, node)\n }\n }\n return null\n}\n\ntype MeasureInWindowCb = (x: number, y: number, width: number, height: number) => void\n\ntype MeasureCb = (\n x: number,\n y: number,\n width: number,\n height: number,\n pageX: number,\n pageY: number\n) => void\n\nexport const measure = async (\n node: HTMLElement,\n callback: MeasureCb\n): Promise<LayoutValue | null> => {\n const out = await measureNode(\n node,\n node.parentNode instanceof HTMLElement ? node.parentNode : null\n )\n if (out) {\n callback?.(out.x, out.y, out.width, out.height, out.pageX, out.pageY)\n }\n return out\n}\n\nexport function createMeasure(\n node: HTMLElement\n): (callback: MeasureCb) => Promise<LayoutValue | null> {\n return (callback) => measure(node, callback)\n}\n\ntype WindowLayout = { pageX: number; pageY: number; width: number; height: number }\n\nexport const measureInWindow = async (\n node: HTMLElement,\n callback: MeasureInWindowCb\n): Promise<WindowLayout | null> => {\n const out = await measureNode(node, null)\n if (out) {\n callback?.(out.pageX, out.pageY, out.width, out.height)\n }\n return out\n}\n\nexport const createMeasureInWindow = (\n node: HTMLElement\n): ((callback: MeasureInWindowCb) => Promise<WindowLayout | null>) => {\n return (callback) => measureInWindow(node, callback)\n}\n\nexport const measureLayout = async (\n node: HTMLElement,\n relativeNode: HTMLElement,\n callback: MeasureCb\n): Promise<LayoutValue | null> => {\n const out = await measureNode(node, relativeNode)\n if (out) {\n callback?.(out.x, out.y, out.width, out.height, out.pageX, out.pageY)\n }\n return out\n}\n\nexport function createMeasureLayout(\n node: HTMLElement\n): (relativeTo: HTMLElement, callback: MeasureCb) => Promise<LayoutValue | null> {\n return (relativeTo, callback) => measureLayout(node, relativeTo, callback)\n}\n"
10
+ ]
11
+ }