@tamagui/use-element-layout 2.0.0-rc.4 → 2.0.0-rc.40
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/index.cjs +282 -132
- package/dist/cjs/index.native.js +356 -197
- package/dist/cjs/index.native.js.map +1 -1
- package/dist/esm/index.js +265 -125
- package/dist/esm/index.js.map +1 -6
- package/dist/esm/index.mjs +267 -120
- package/dist/esm/index.mjs.map +1 -1
- package/dist/esm/index.native.js +341 -185
- package/dist/esm/index.native.js.map +1 -1
- package/package.json +7 -10
- package/src/index.tsx +280 -89
- package/types/index.d.ts +2 -1
- package/types/index.d.ts.map +4 -4
- package/dist/cjs/index.js +0 -198
- package/dist/cjs/index.js.map +0 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tamagui/use-element-layout",
|
|
3
|
-
"version": "2.0.0-rc.
|
|
3
|
+
"version": "2.0.0-rc.40",
|
|
4
4
|
"gitHead": "a49cc7ea6b93ba384e77a4880ae48ac4a5635c14",
|
|
5
5
|
"files": [
|
|
6
6
|
"src",
|
|
@@ -16,15 +16,12 @@
|
|
|
16
16
|
"./package.json": "./package.json",
|
|
17
17
|
".": {
|
|
18
18
|
"types": "./types/index.d.ts",
|
|
19
|
-
"react-native":
|
|
20
|
-
|
|
21
|
-
"import": "./dist/esm/index.native.js",
|
|
22
|
-
"require": "./dist/cjs/index.native.js"
|
|
23
|
-
},
|
|
19
|
+
"react-native": "./dist/esm/index.native.js",
|
|
20
|
+
"browser": "./dist/esm/index.mjs",
|
|
24
21
|
"module": "./dist/esm/index.mjs",
|
|
25
22
|
"import": "./dist/esm/index.mjs",
|
|
26
23
|
"require": "./dist/cjs/index.cjs",
|
|
27
|
-
"default": "./dist/
|
|
24
|
+
"default": "./dist/esm/index.mjs"
|
|
28
25
|
}
|
|
29
26
|
},
|
|
30
27
|
"publishConfig": {
|
|
@@ -37,11 +34,11 @@
|
|
|
37
34
|
"clean:build": "tamagui-build clean:build"
|
|
38
35
|
},
|
|
39
36
|
"dependencies": {
|
|
40
|
-
"@tamagui/constants": "2.0.0-rc.
|
|
41
|
-
"@tamagui/is-equal-shallow": "2.0.0-rc.
|
|
37
|
+
"@tamagui/constants": "2.0.0-rc.40",
|
|
38
|
+
"@tamagui/is-equal-shallow": "2.0.0-rc.40"
|
|
42
39
|
},
|
|
43
40
|
"devDependencies": {
|
|
44
|
-
"@tamagui/build": "2.0.0-rc.
|
|
41
|
+
"@tamagui/build": "2.0.0-rc.40",
|
|
45
42
|
"react": ">=19"
|
|
46
43
|
},
|
|
47
44
|
"peerDependencies": {
|
package/src/index.tsx
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { useIsomorphicLayoutEffect } from '@tamagui/constants'
|
|
2
|
-
import { isEqualShallow } from '@tamagui/is-equal-shallow'
|
|
3
2
|
import { createContext, useContext, useId, type ReactNode, type RefObject } from 'react'
|
|
4
3
|
|
|
5
4
|
const LayoutHandlers = new WeakMap<HTMLElement, Function>()
|
|
@@ -7,6 +6,24 @@ const LayoutDisableKey = new WeakMap<HTMLElement, string>()
|
|
|
7
6
|
const Nodes = new Set<HTMLElement>()
|
|
8
7
|
const IntersectionState = new WeakMap<HTMLElement, boolean>()
|
|
9
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/tamagui/tamagui/pull/2329
|
|
12
|
+
const usePretransformDimensions = () =>
|
|
13
|
+
(globalThis as any).__TAMAGUI_ONLAYOUT_PRETRANSFORM === true ||
|
|
14
|
+
process.env.TAMAGUI_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
|
+
|
|
10
27
|
// separating to avoid all re-rendering
|
|
11
28
|
const DisableLayoutContextValues: Record<string, boolean> = {}
|
|
12
29
|
const DisableLayoutContextKey = createContext<string>('')
|
|
@@ -37,7 +54,7 @@ export const LayoutMeasurementController = ({
|
|
|
37
54
|
)
|
|
38
55
|
}
|
|
39
56
|
|
|
40
|
-
// Single persistent IntersectionObserver for
|
|
57
|
+
// Single persistent IntersectionObserver for visibility tracking
|
|
41
58
|
let globalIntersectionObserver: IntersectionObserver | null = null
|
|
42
59
|
|
|
43
60
|
type TamaguiComponentStatePartial = {
|
|
@@ -70,7 +87,6 @@ export type LayoutEvent = {
|
|
|
70
87
|
}
|
|
71
88
|
|
|
72
89
|
const NodeRectCache = new WeakMap<HTMLElement, DOMRect>()
|
|
73
|
-
const LastChangeTime = new WeakMap<HTMLElement, number>()
|
|
74
90
|
|
|
75
91
|
// prevent thrashing during first hydration (somewhat, streaming gets trickier)
|
|
76
92
|
let avoidUpdates = true
|
|
@@ -91,12 +107,13 @@ function startGlobalObservers() {
|
|
|
91
107
|
|
|
92
108
|
globalIntersectionObserver = new IntersectionObserver(
|
|
93
109
|
(entries) => {
|
|
94
|
-
entries.
|
|
110
|
+
for (let i = 0; i < entries.length; i++) {
|
|
111
|
+
const entry = entries[i]
|
|
95
112
|
const node = entry.target as HTMLElement
|
|
96
113
|
if (IntersectionState.get(node) !== entry.isIntersecting) {
|
|
97
114
|
IntersectionState.set(node, entry.isIntersecting)
|
|
98
115
|
}
|
|
99
|
-
}
|
|
116
|
+
}
|
|
100
117
|
},
|
|
101
118
|
{
|
|
102
119
|
threshold: 0,
|
|
@@ -104,8 +121,57 @@ function startGlobalObservers() {
|
|
|
104
121
|
)
|
|
105
122
|
}
|
|
106
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
|
+
|
|
107
129
|
if (ENABLE) {
|
|
108
|
-
const BoundingRects = new WeakMap<
|
|
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
|
+
}
|
|
109
175
|
|
|
110
176
|
async function updateLayoutIfChanged(node: HTMLElement) {
|
|
111
177
|
const onLayout = LayoutHandlers.get(node)
|
|
@@ -114,46 +180,44 @@ if (ENABLE) {
|
|
|
114
180
|
const parentNode = node.parentElement
|
|
115
181
|
if (!parentNode) return
|
|
116
182
|
|
|
117
|
-
let nodeRect: DOMRectReadOnly
|
|
118
|
-
let parentRect: DOMRectReadOnly
|
|
183
|
+
let nodeRect: DOMRectReadOnly | undefined
|
|
184
|
+
let parentRect: DOMRectReadOnly | undefined
|
|
119
185
|
|
|
186
|
+
// respect the strategy contract
|
|
120
187
|
if (strategy === 'async') {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
BoundingRects.get(parentNode),
|
|
124
|
-
])
|
|
188
|
+
nodeRect = BoundingRects.get(node)
|
|
189
|
+
parentRect = BoundingRects.get(parentNode)
|
|
125
190
|
|
|
126
|
-
if (!
|
|
191
|
+
if (!nodeRect || !parentRect) {
|
|
127
192
|
return
|
|
128
193
|
}
|
|
129
|
-
|
|
130
|
-
nodeRect = nr
|
|
131
|
-
parentRect = pr
|
|
132
194
|
} else {
|
|
133
195
|
nodeRect = node.getBoundingClientRect()
|
|
134
196
|
parentRect = parentNode.getBoundingClientRect()
|
|
135
197
|
}
|
|
136
198
|
|
|
137
|
-
if (!nodeRect || !parentRect) {
|
|
138
|
-
return
|
|
139
|
-
}
|
|
140
|
-
|
|
141
199
|
const cachedRect = NodeRectCache.get(node)
|
|
142
200
|
const cachedParentRect = NodeRectCache.get(parentNode)
|
|
143
201
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
// has changed one rect
|
|
148
|
-
// @ts-expect-error DOMRectReadOnly can go into object
|
|
149
|
-
!isEqualShallow(cachedRect, nodeRect) ||
|
|
150
|
-
// @ts-expect-error DOMRectReadOnly can go into object
|
|
151
|
-
!isEqualShallow(cachedParentRect, parentRect)
|
|
152
|
-
) {
|
|
153
|
-
NodeRectCache.set(node, nodeRect)
|
|
154
|
-
NodeRectCache.set(parentNode, parentRect)
|
|
202
|
+
// optimization: inline comparison instead of isEqualShallow
|
|
203
|
+
const nodeChanged = !cachedRect || !rectsEqual(cachedRect, nodeRect)
|
|
204
|
+
const parentChanged = !cachedParentRect || !rectsEqual(cachedParentRect, parentRect)
|
|
155
205
|
|
|
156
|
-
|
|
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
|
+
}
|
|
157
221
|
|
|
158
222
|
if (avoidUpdates) {
|
|
159
223
|
queuedUpdates.set(node, () => onLayout(event))
|
|
@@ -163,58 +227,91 @@ if (ENABLE) {
|
|
|
163
227
|
}
|
|
164
228
|
}
|
|
165
229
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
// which is why we use async strategy in general
|
|
230
|
+
const rAF =
|
|
231
|
+
typeof requestAnimationFrame !== 'undefined' ? requestAnimationFrame : undefined
|
|
169
232
|
|
|
233
|
+
// adaptive frame skipping with backoff
|
|
170
234
|
const userSkipVal = process.env.TAMAGUI_LAYOUT_FRAME_SKIP
|
|
171
|
-
const
|
|
235
|
+
const BASE_SKIP_FRAMES = userSkipVal ? +userSkipVal : 10
|
|
236
|
+
const MAX_SKIP_FRAMES = 20
|
|
237
|
+
let skipFrames = BASE_SKIP_FRAMES
|
|
238
|
+
let frameCount = 0
|
|
172
239
|
|
|
173
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
|
+
|
|
174
252
|
if (strategy !== 'off') {
|
|
175
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
|
|
176
267
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
(entries) => {
|
|
181
|
-
io.disconnect()
|
|
182
|
-
for (const entry of entries) {
|
|
183
|
-
BoundingRects.set(entry.target, entry.boundingClientRect)
|
|
184
|
-
}
|
|
185
|
-
res(true)
|
|
186
|
-
},
|
|
187
|
-
{
|
|
188
|
-
threshold: 0,
|
|
189
|
-
}
|
|
190
|
-
)
|
|
268
|
+
visibleNodes.push(node)
|
|
269
|
+
parentsToObserve.add(parentElement)
|
|
270
|
+
}
|
|
191
271
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
if (!(node.parentElement instanceof HTMLElement)) continue
|
|
196
|
-
const disableKey = LayoutDisableKey.get(node)
|
|
197
|
-
if (disableKey && DisableLayoutContextValues[disableKey] === true) continue
|
|
198
|
-
if (IntersectionState.get(node) === false) continue
|
|
199
|
-
didObserve = true
|
|
200
|
-
io.observe(node)
|
|
201
|
-
io.observe(node.parentElement)
|
|
202
|
-
visibleNodes.push(node)
|
|
203
|
-
}
|
|
272
|
+
if (visibleNodes.length > 0) {
|
|
273
|
+
const io = ensureRectFetchObserver()
|
|
274
|
+
rectFetchStartTime = performance.now()
|
|
204
275
|
|
|
205
|
-
|
|
206
|
-
|
|
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)
|
|
207
283
|
}
|
|
208
|
-
})
|
|
209
284
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
285
|
+
// wait for callback
|
|
286
|
+
await new Promise<boolean>((res) => {
|
|
287
|
+
rectFetchResolve = res
|
|
213
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
|
+
}
|
|
214
310
|
}
|
|
215
311
|
}
|
|
216
312
|
|
|
217
|
-
|
|
313
|
+
// schedule next frame
|
|
314
|
+
rAF ? rAF(layoutOnAnimationFrame) : setTimeout(layoutOnAnimationFrame, 16)
|
|
218
315
|
}
|
|
219
316
|
|
|
220
317
|
layoutOnAnimationFrame()
|
|
@@ -222,37 +319,125 @@ if (ENABLE) {
|
|
|
222
319
|
|
|
223
320
|
export const getElementLayoutEvent = (
|
|
224
321
|
nodeRect: DOMRectReadOnly,
|
|
225
|
-
parentRect: DOMRectReadOnly
|
|
322
|
+
parentRect: DOMRectReadOnly,
|
|
323
|
+
node?: HTMLElement
|
|
226
324
|
): LayoutEvent => {
|
|
227
325
|
return {
|
|
228
326
|
nativeEvent: {
|
|
229
|
-
layout: getRelativeDimensions(nodeRect, parentRect),
|
|
327
|
+
layout: getRelativeDimensions(nodeRect, parentRect, node),
|
|
230
328
|
target: nodeRect,
|
|
231
329
|
},
|
|
232
330
|
timeStamp: Date.now(),
|
|
233
331
|
}
|
|
234
332
|
}
|
|
235
333
|
|
|
236
|
-
|
|
237
|
-
|
|
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/tamagui/tamagui/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
|
|
238
356
|
const x = left - b.left
|
|
239
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
|
+
|
|
240
365
|
return { x, y, width, height, pageX: a.left, pageY: a.top }
|
|
241
366
|
}
|
|
242
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
|
+
|
|
243
398
|
export function useElementLayout(
|
|
244
399
|
ref: RefObject<TamaguiComponentStatePartial>,
|
|
245
400
|
onLayout?: ((e: LayoutEvent) => void) | null
|
|
246
401
|
): void {
|
|
247
402
|
const disableKey = useContext(DisableLayoutContextKey)
|
|
248
403
|
|
|
249
|
-
//
|
|
404
|
+
// keep handlers up to date so polling always calls the latest callback
|
|
250
405
|
const node = ensureWebElement(ref.current?.host)
|
|
251
406
|
if (node && onLayout) {
|
|
252
407
|
LayoutHandlers.set(node, onLayout)
|
|
253
408
|
LayoutDisableKey.set(node, disableKey)
|
|
254
409
|
}
|
|
255
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
|
+
|
|
256
441
|
useIsomorphicLayoutEffect(() => {
|
|
257
442
|
if (!onLayout) return
|
|
258
443
|
const node = ref.current?.host
|
|
@@ -260,36 +445,42 @@ export function useElementLayout(
|
|
|
260
445
|
|
|
261
446
|
Nodes.add(node)
|
|
262
447
|
|
|
263
|
-
// Add node to intersection observer
|
|
264
448
|
startGlobalObservers()
|
|
265
449
|
if (globalIntersectionObserver) {
|
|
266
450
|
globalIntersectionObserver.observe(node)
|
|
267
|
-
// Initialize as intersecting by default
|
|
268
451
|
IntersectionState.set(node, true)
|
|
269
452
|
}
|
|
270
453
|
|
|
271
|
-
|
|
272
|
-
|
|
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
|
|
273
465
|
if (parentNode) {
|
|
274
466
|
onLayout(
|
|
275
467
|
getElementLayoutEvent(
|
|
276
468
|
node.getBoundingClientRect(),
|
|
277
|
-
parentNode.getBoundingClientRect()
|
|
469
|
+
parentNode.getBoundingClientRect(),
|
|
470
|
+
node
|
|
278
471
|
)
|
|
279
472
|
)
|
|
280
473
|
}
|
|
281
474
|
|
|
282
475
|
return () => {
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
// Remove from intersection observer
|
|
290
|
-
if (globalIntersectionObserver) {
|
|
291
|
-
globalIntersectionObserver.unobserve(node)
|
|
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)
|
|
292
482
|
}
|
|
483
|
+
PrevHostNode.delete(ref)
|
|
293
484
|
}
|
|
294
485
|
}, [ref, !!onLayout])
|
|
295
486
|
}
|
|
@@ -331,7 +522,7 @@ export const measureNode = async (
|
|
|
331
522
|
getBoundingClientRectAsync(relativeNode),
|
|
332
523
|
])
|
|
333
524
|
if (relativeNodeDim && nodeDim) {
|
|
334
|
-
return getRelativeDimensions(nodeDim, relativeNodeDim)
|
|
525
|
+
return getRelativeDimensions(nodeDim, relativeNodeDim, node)
|
|
335
526
|
}
|
|
336
527
|
}
|
|
337
528
|
return null
|
package/types/index.d.ts
CHANGED
|
@@ -24,7 +24,8 @@ export type LayoutEvent = {
|
|
|
24
24
|
timeStamp: number;
|
|
25
25
|
};
|
|
26
26
|
export declare function enable(): void;
|
|
27
|
-
export declare const getElementLayoutEvent: (nodeRect: DOMRectReadOnly, parentRect: DOMRectReadOnly) => LayoutEvent;
|
|
27
|
+
export declare const getElementLayoutEvent: (nodeRect: DOMRectReadOnly, parentRect: DOMRectReadOnly, node?: HTMLElement) => LayoutEvent;
|
|
28
|
+
export declare function registerLayoutNode(node: HTMLElement, onChange: () => void, disableKey?: string): () => void;
|
|
28
29
|
export declare function useElementLayout(ref: RefObject<TamaguiComponentStatePartial>, onLayout?: ((e: LayoutEvent) => void) | null): void;
|
|
29
30
|
export declare const getBoundingClientRectAsync: (node: HTMLElement | null) => Promise<DOMRectReadOnly | false>;
|
|
30
31
|
export declare const measureNode: (node: HTMLElement, relativeTo?: HTMLElement | null) => Promise<null | LayoutValue>;
|
package/types/index.d.ts.map
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
|
-
"mappings": "
|
|
2
|
+
"mappings": "AACA,cAAgD,gBAAgB,iBAAiB;AAmCjF,OAAO,cAAM,8BAA+B,EAC1C,SACA,YACC;CACD;CACA,UAAU;MACR;KAiBC,+BAA+B;CAClC;;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,+BACf,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
3
|
"names": [],
|
|
4
4
|
"sources": [
|
|
5
5
|
"src/index.tsx"
|
|
6
6
|
],
|
|
7
|
+
"version": 3,
|
|
7
8
|
"sourcesContent": [
|
|
8
|
-
"import { useIsomorphicLayoutEffect } from '@tamagui/constants'\nimport { isEqualShallow } from '@tamagui/is-equal-shallow'\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// separating to avoid all re-rendering\nconst DisableLayoutContextValues: Record<string, boolean> = {}\nconst DisableLayoutContextKey = createContext<string>('')\n\nconst ENABLE =\n process.env.TAMAGUI_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 all nodes\nlet globalIntersectionObserver: IntersectionObserver | null = null\n\ntype TamaguiComponentStatePartial = {\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>()\nconst LastChangeTime = new WeakMap<HTMLElement, number>()\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 entries.forEach((entry) => {\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\nif (ENABLE) {\n const BoundingRects = new WeakMap<any, DOMRectReadOnly | undefined>()\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\n let parentRect: DOMRectReadOnly\n\n if (strategy === 'async') {\n const [nr, pr] = await Promise.all([\n BoundingRects.get(node),\n BoundingRects.get(parentNode),\n ])\n\n if (!nr || !pr) {\n return\n }\n\n nodeRect = nr\n parentRect = pr\n } else {\n nodeRect = node.getBoundingClientRect()\n parentRect = parentNode.getBoundingClientRect()\n }\n\n if (!nodeRect || !parentRect) {\n return\n }\n\n const cachedRect = NodeRectCache.get(node)\n const cachedParentRect = NodeRectCache.get(parentNode)\n\n if (\n !cachedRect ||\n !cachedParentRect ||\n // has changed one rect\n // @ts-expect-error DOMRectReadOnly can go into object\n !isEqualShallow(cachedRect, nodeRect) ||\n // @ts-expect-error DOMRectReadOnly can go into object\n !isEqualShallow(cachedParentRect, parentRect)\n ) {\n NodeRectCache.set(node, nodeRect)\n NodeRectCache.set(parentNode, parentRect)\n\n const event = getElementLayoutEvent(nodeRect, parentRect)\n\n if (avoidUpdates) {\n queuedUpdates.set(node, () => onLayout(event))\n } else {\n onLayout(event)\n }\n }\n }\n\n // note that getBoundingClientRect() does not thrash layout if its after an animation frame\n // ok new note: *if* it needed recalc then yea, but browsers often skip that, so it does\n // which is why we use async strategy in general\n\n const userSkipVal = process.env.TAMAGUI_LAYOUT_FRAME_SKIP\n const RUN_EVERY_X_FRAMES = userSkipVal ? +userSkipVal : 14\n\n async function layoutOnAnimationFrame() {\n if (strategy !== 'off') {\n const visibleNodes: HTMLElement[] = []\n\n // do a 1 rather than N IntersectionObservers for performance\n const didRun = await new Promise<boolean>((res) => {\n const io = new IntersectionObserver(\n (entries) => {\n io.disconnect()\n for (const entry of entries) {\n BoundingRects.set(entry.target, entry.boundingClientRect)\n }\n res(true)\n },\n {\n threshold: 0,\n }\n )\n\n let didObserve = false\n\n for (const node of Nodes) {\n if (!(node.parentElement instanceof HTMLElement)) continue\n const disableKey = LayoutDisableKey.get(node)\n if (disableKey && DisableLayoutContextValues[disableKey] === true) continue\n if (IntersectionState.get(node) === false) continue\n didObserve = true\n io.observe(node)\n io.observe(node.parentElement)\n visibleNodes.push(node)\n }\n\n if (!didObserve) {\n res(false)\n }\n })\n\n if (didRun) {\n visibleNodes.forEach((node) => {\n updateLayoutIfChanged(node)\n })\n }\n }\n\n setTimeout(layoutOnAnimationFrame, 16.6667 * RUN_EVERY_X_FRAMES)\n }\n\n layoutOnAnimationFrame()\n}\n\nexport const getElementLayoutEvent = (\n nodeRect: DOMRectReadOnly,\n parentRect: DOMRectReadOnly\n): LayoutEvent => {\n return {\n nativeEvent: {\n layout: getRelativeDimensions(nodeRect, parentRect),\n target: nodeRect,\n },\n timeStamp: Date.now(),\n }\n}\n\nconst getRelativeDimensions = (a: DOMRectReadOnly, b: DOMRectReadOnly) => {\n const { height, left, top, width } = a\n const x = left - b.left\n const y = top - b.top\n return { x, y, width, height, pageX: a.left, pageY: a.top }\n}\n\nexport function useElementLayout(\n ref: RefObject<TamaguiComponentStatePartial>,\n onLayout?: ((e: LayoutEvent) => void) | null\n): void {\n const disableKey = useContext(DisableLayoutContextKey)\n\n // ensure always up to date so we can avoid re-running effect\n const node = ensureWebElement(ref.current?.host)\n if (node && onLayout) {\n LayoutHandlers.set(node, onLayout)\n LayoutDisableKey.set(node, disableKey)\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 // Add node to intersection observer\n startGlobalObservers()\n if (globalIntersectionObserver) {\n globalIntersectionObserver.observe(node)\n // Initialize as intersecting by default\n IntersectionState.set(node, true)\n }\n\n // always do one immediate sync layout event no matter the strategy for accuracy\n const parentNode = node.parentNode\n if (parentNode) {\n onLayout(\n getElementLayoutEvent(\n node.getBoundingClientRect(),\n parentNode.getBoundingClientRect()\n )\n )\n }\n\n return () => {\n Nodes.delete(node)\n LayoutHandlers.delete(node)\n NodeRectCache.delete(node)\n LastChangeTime.delete(node)\n IntersectionState.delete(node)\n\n // Remove from intersection observer\n if (globalIntersectionObserver) {\n globalIntersectionObserver.unobserve(node)\n }\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)\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"
|
|
9
|
-
]
|
|
10
|
-
"version": 3
|
|
9
|
+
"import { useIsomorphicLayoutEffect } from '@tamagui/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/tamagui/tamagui/pull/2329\nconst usePretransformDimensions = () =>\n (globalThis as any).__TAMAGUI_ONLAYOUT_PRETRANSFORM === true ||\n process.env.TAMAGUI_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.TAMAGUI_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 TamaguiComponentStatePartial = {\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.TAMAGUI_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/tamagui/tamagui/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<TamaguiComponentStatePartial>,\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
11
|
}
|