@tamagui/adapt 2.7.7 → 3.0.0-beta.1093.1

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/Adapt.tsx CHANGED
@@ -8,53 +8,49 @@ import {
8
8
  import type { AllPlatforms, MediaQueryKey } from '@tamagui/core'
9
9
  import { createStyledContext, useMedia } from '@tamagui/core'
10
10
  import { withStaticProperties } from '@tamagui/helpers'
11
- import { getPortal } from '@tamagui/native'
12
- import { PortalHost, PortalItem } from '@tamagui/portal'
13
11
  import { StackZIndexContext } from '@tamagui/z-index-stack'
14
- import React, { createContext, useContext, useId, useMemo } from 'react'
15
-
16
- /**
17
- * External store for passing children from AdaptPortalContents to Adapt.Contents
18
- * when teleport is enabled. This bypasses PortalItem to avoid nested teleport
19
- * (inner portal inside an already-teleported Sheet) which breaks touch
20
- * coordinate mapping on iOS.
21
- */
22
- type AdaptChildrenStore = {
23
- set(children: React.ReactNode): void
24
- get(): React.ReactNode
12
+ import React, { createContext, useContext, useId } from 'react'
13
+
14
+ type AdaptSlotStore = {
15
+ element: React.ReactNode
16
+ version: number
17
+ publish(element: React.ReactNode): void
18
+ clear(): void
19
+ notify(): void
20
+ getSnapshot(): number
25
21
  subscribe(callback: () => void): () => void
26
22
  }
27
23
 
28
- function createAdaptChildrenStore(): AdaptChildrenStore {
29
- let children: React.ReactNode = null
24
+ function createAdaptSlotStore(): AdaptSlotStore {
30
25
  const listeners = new Set<() => void>()
31
- return {
32
- set(c) {
33
- children = c
34
- for (const l of listeners) l()
26
+
27
+ const store: AdaptSlotStore = {
28
+ element: null,
29
+ version: 0,
30
+ publish(element) {
31
+ store.element = element
32
+ },
33
+ clear() {
34
+ store.element = null
35
+ },
36
+ notify() {
37
+ store.version += 1
38
+ for (const listener of listeners) {
39
+ listener()
40
+ }
41
+ },
42
+ getSnapshot() {
43
+ return store.version
35
44
  },
36
- get: () => children,
37
45
  subscribe(callback) {
38
46
  listeners.add(callback)
39
- return () => listeners.delete(callback)
47
+ return () => {
48
+ listeners.delete(callback)
49
+ }
40
50
  },
41
51
  }
42
- }
43
52
 
44
- const AdaptChildrenStoreContext = createContext<AdaptChildrenStore | null>(null)
45
-
46
- const emptySubscribe = () => () => {}
47
- const emptyGet = () => null
48
-
49
- /** Renders adapt children from external store (used when teleport is enabled) */
50
- function TeleportAdaptContents() {
51
- const store = useContext(AdaptChildrenStoreContext)
52
- const children = React.useSyncExternalStore(
53
- store?.subscribe ?? emptySubscribe,
54
- store?.get ?? emptyGet,
55
- store?.get ?? emptyGet
56
- )
57
- return <>{children}</>
53
+ return store
58
54
  }
59
55
 
60
56
  /**
@@ -63,38 +59,125 @@ function TeleportAdaptContents() {
63
59
 
64
60
  export type AdaptWhen = MediaQueryKeyString | boolean | null
65
61
  export type AdaptPlatform = AllPlatforms | 'touch' | null
62
+ export type AdaptCapabilitiesValue = {
63
+ scroll?: boolean
64
+ overlay?: boolean
65
+ dismiss?: boolean
66
+ }
67
+
68
+ // structurally matches @tamagui/sheet's SheetTransitionEvent (adapt cannot
69
+ // import it — sheet depends on adapt). the adapt target reports its position
70
+ // transition here so the parent can release the presence latch on real close
71
+ // completion instead of a timer.
72
+ export type AdaptTargetTransitionEvent = {
73
+ phase: 'start' | 'end'
74
+ cause: 'open' | 'close' | 'snap'
75
+ finished?: boolean
76
+ }
77
+
78
+ export type AdaptTargetHandoff = {
79
+ hidden: boolean
80
+ skipNextAnimation?: boolean
81
+ onTransition: (e: AdaptTargetTransitionEvent) => void
82
+ }
83
+
84
+ export type AdaptTarget<State = unknown> = {
85
+ open?: boolean
86
+ onOpenChange?: (open: boolean) => void
87
+ handoff: AdaptTargetHandoff
88
+ state: State
89
+ }
90
+
91
+ export type AdaptConfig = Pick<AdaptProps, 'when' | 'platform'>
66
92
 
67
93
  export type AdaptParentContextI = {
68
94
  Contents: Component
69
95
  scopeName: string
70
- platform: AdaptPlatform
71
- setPlatform: (when: AdaptPlatform) => any
72
- when: AdaptWhen
73
- setWhen: (when: AdaptWhen) => any
96
+ active: boolean
97
+ setAdaptConfig: (config: AdaptConfig | null) => void
74
98
  portalName?: string
75
99
  lastScope?: string
100
+ slot: AdaptSlotStore | null
101
+ open?: boolean
102
+ onOpenChange?: (open: boolean) => void
103
+ state?: unknown
104
+ handoff: AdaptTargetHandoff
105
+ targetFullyHidden: boolean
106
+ registerTarget: () => void
107
+ unregisterTarget: () => void
108
+ registerContents: () => void
109
+ unregisterContents: () => void
110
+ registerRenderCallback: () => void
111
+ unregisterRenderCallback: () => void
76
112
  }
77
113
 
78
114
  type MediaQueryKeyString = MediaQueryKey extends string ? MediaQueryKey : never
79
115
 
116
+ export type AdaptRenderState<State = unknown> = {
117
+ active: boolean
118
+ open?: boolean
119
+ onOpenChange?: (open: boolean) => void
120
+ state: State
121
+ handoff: AdaptTargetHandoff
122
+ }
123
+
80
124
  export type AdaptProps = {
81
125
  scope?: string
82
126
  when?: AdaptWhen
83
127
  platform?: AdaptPlatform
84
- children: React.JSX.Element | ((children: React.ReactNode) => React.ReactNode)
128
+ children:
129
+ | React.JSX.Element
130
+ | ((contents: React.ReactNode, adapt: AdaptRenderState) => React.ReactNode)
85
131
  }
86
132
 
87
133
  type Component = (props: any) => any
88
134
 
89
- export const AdaptContext = createStyledContext<AdaptParentContextI>({
90
- Contents: null as any,
91
- scopeName: '',
92
- portalName: '',
93
- platform: null as any,
94
- setPlatform: (x: AdaptPlatform) => {},
95
- when: null as any,
96
- setWhen: () => {},
97
- })
135
+ const adaptContextKeys = [
136
+ 'Contents',
137
+ 'scopeName',
138
+ 'portalName',
139
+ 'active',
140
+ 'setAdaptConfig',
141
+ 'slot',
142
+ 'handoff',
143
+ 'targetFullyHidden',
144
+ 'registerTarget',
145
+ 'unregisterTarget',
146
+ 'registerContents',
147
+ 'unregisterContents',
148
+ 'registerRenderCallback',
149
+ 'unregisterRenderCallback',
150
+ ] as const
151
+
152
+ export const AdaptContext = createStyledContext<
153
+ AdaptParentContextI,
154
+ (typeof adaptContextKeys)[number]
155
+ >(
156
+ {
157
+ Contents: null as any,
158
+ scopeName: '',
159
+ portalName: '',
160
+ active: false,
161
+ setAdaptConfig: () => {},
162
+ slot: null,
163
+ handoff: {
164
+ hidden: true,
165
+ onTransition: () => {},
166
+ },
167
+ targetFullyHidden: true,
168
+ registerTarget: () => {},
169
+ unregisterTarget: () => {},
170
+ registerContents: () => {},
171
+ unregisterContents: () => {},
172
+ registerRenderCallback: () => {},
173
+ unregisterRenderCallback: () => {},
174
+ },
175
+ {
176
+ keys: adaptContextKeys,
177
+ }
178
+ )
179
+
180
+ const AdaptCapabilitiesContext = createContext<AdaptCapabilitiesValue>({})
98
181
 
99
182
  const LastAdaptContextScope = createContext('')
100
183
 
@@ -130,88 +213,252 @@ export const useAdaptContext = (scope?: string) => {
130
213
 
131
214
  type AdaptParentProps = {
132
215
  children?: React.ReactNode
216
+ // the children the user gave the adapting component, where their <Adapt /> lives.
217
+ // AdaptParent's own children are that component's internals, one wrapper deep.
218
+ adaptChildren?: React.ReactNode
133
219
  Contents?: AdaptParentContextI['Contents']
134
220
  scope: string
135
- portal?:
136
- | boolean
137
- | {
138
- forwardProps?: any
139
- }
221
+ open?: boolean
222
+ onOpenChange?: (open: boolean) => void
223
+ state?: unknown
140
224
  }
141
225
 
142
- const AdaptPortals = new Map()
226
+ // an <Adapt /> written into the adapting component's children is read off the
227
+ // element tree during render. learning it from the child's layout effect left
228
+ // every consumer seeing "inactive" on the first commit, so an adapted Select
229
+ // mounted SelectInlineImpl and swapped it for SelectSheetImpl one commit later,
230
+ // remounting the whole subtree. only direct children are scanned, descending
231
+ // into fragments: any deeper and a nested adapting component's own <Adapt />
232
+ // would adapt this one too. Children.toArray already flattens arrays and drops
233
+ // false, so `{cond && <Adapt />}` still reads. an <Adapt /> that a userland
234
+ // component renders is not in this tree, so it reports itself from its effect
235
+ // instead and still costs that extra commit.
236
+ function findAdaptConfig(children: React.ReactNode, scope: string): AdaptConfig | null {
237
+ for (const child of React.Children.toArray(children)) {
238
+ if (!React.isValidElement(child)) continue
239
+
240
+ if (child.type === React.Fragment) {
241
+ const found = findAdaptConfig(
242
+ (child.props as { children?: React.ReactNode }).children,
243
+ scope
244
+ )
245
+ if (found) return found
246
+ continue
247
+ }
248
+
249
+ if (child.type === Adapt) {
250
+ const childProps = child.props as AdaptProps
251
+ if (childProps.scope == null || childProps.scope === scope) {
252
+ return childProps
253
+ }
254
+ }
255
+ }
143
256
 
144
- export const AdaptParent = ({ children, Contents, scope, portal }: AdaptParentProps) => {
257
+ return null
258
+ }
259
+
260
+ export const AdaptParent = ({
261
+ children,
262
+ adaptChildren,
263
+ Contents,
264
+ scope,
265
+ open,
266
+ onOpenChange,
267
+ state,
268
+ }: AdaptParentProps) => {
145
269
  const id = useId()
146
270
  const portalName = `AdaptPortal${scope}${id}`
147
271
 
148
- const childrenStoreRef = React.useRef<AdaptChildrenStore | null>(null)
149
- if (!childrenStoreRef.current) {
150
- childrenStoreRef.current = createAdaptChildrenStore()
272
+ const slotRef = React.useRef<AdaptSlotStore | null>(null)
273
+ if (!slotRef.current) {
274
+ slotRef.current = createAdaptSlotStore()
151
275
  }
152
276
 
153
- const isTeleport =
154
- process.env.TAMAGUI_TARGET === 'native' && getPortal().state.type === 'teleport'
277
+ const staticConfig = React.useMemo(
278
+ () => findAdaptConfig(adaptChildren, scope) ?? findAdaptConfig(children, scope),
279
+ [adaptChildren, children, scope]
280
+ )
281
+ const [publishedConfig, setPublishedConfig] = React.useState<AdaptConfig | null>(null)
282
+ const rawActive = useAdaptIsActiveGiven(staticConfig ?? publishedConfig)
283
+
284
+ // written during render because a child's layout effect calls setAdaptConfig
285
+ // before this component's own effects run
286
+ const staticConfigRef = React.useRef(staticConfig)
287
+ staticConfigRef.current = staticConfig
288
+
289
+ const setAdaptConfig = React.useCallback((config: AdaptConfig | null) => {
290
+ if (staticConfigRef.current) return
291
+ setPublishedConfig(config)
292
+ }, [])
293
+
294
+ const [exiting, setExiting] = React.useState(false)
295
+ const [present, setPresent] = React.useState(false)
296
+ const [targetFullyHidden, setTargetFullyHidden] = React.useState(!open)
297
+ const targetCountRef = React.useRef(0)
298
+ const contentsCountRef = React.useRef(0)
299
+ const renderCallbackCountRef = React.useRef(0)
300
+ const rawActiveRef = React.useRef(false)
301
+ const openRef = React.useRef(open)
302
+ const wasTargetHiddenRef = React.useRef(!rawActive)
303
+ const hasHadActiveTargetRef = React.useRef(false)
304
+
305
+ const shouldStartExit = !rawActive && present && Boolean(open)
306
+ const active = rawActive || exiting || shouldStartExit
307
+ const targetHidden = !rawActive
308
+ const skipNextAnimation = Boolean(
309
+ rawActive && wasTargetHiddenRef.current && hasHadActiveTargetRef.current && open
310
+ )
155
311
 
156
- const FinalContents = useMemo(() => {
157
- if (Contents) {
158
- return Contents
312
+ const releasePresenceLatch = React.useCallback(() => {
313
+ setExiting(false)
314
+ if (!rawActiveRef.current) {
315
+ setPresent(false)
159
316
  }
317
+ }, [])
160
318
 
161
- // when teleport is enabled, use store-based children passing to avoid
162
- // nested teleport (inner PortalItem inside already-teleported Sheet)
163
- // which breaks touch coordinate mapping on iOS.
164
- if (isTeleport) {
165
- return TeleportAdaptContents
319
+ useIsomorphicLayoutEffect(() => {
320
+ if (shouldStartExit) {
321
+ setExiting(true)
166
322
  }
323
+ }, [shouldStartExit])
167
324
 
168
- if (AdaptPortals.has(portalName)) {
169
- return AdaptPortals.get(portalName)
325
+ useIsomorphicLayoutEffect(() => {
326
+ // committed snapshot: skipNextAnimation compares the next render against
327
+ // these, and onTransition reads them non-reactively
328
+ rawActiveRef.current = rawActive
329
+ openRef.current = open
330
+ if (rawActive) {
331
+ hasHadActiveTargetRef.current = true
170
332
  }
333
+ wasTargetHiddenRef.current = targetHidden
171
334
 
172
- const element = () => {
173
- return (
174
- <PortalHost
175
- key={id}
176
- name={portalName}
177
- forwardProps={typeof portal === 'boolean' ? undefined : portal?.forwardProps}
178
- />
179
- )
335
+ if (open && rawActive) {
336
+ setTargetFullyHidden(false)
337
+ return
180
338
  }
181
339
 
182
- AdaptPortals.set(portalName, element)
183
-
184
- return element
185
- }, [portalName, Contents, isTeleport])
340
+ // once closed and the target is no longer active (exit finished or never
341
+ // started), mark it hidden. an active exit waits for the target's
342
+ // close-complete transition to fire the handoff below.
343
+ if (!open && !active) {
344
+ setTargetFullyHidden(true)
345
+ }
346
+ }, [active, open, rawActive, targetHidden])
186
347
 
187
348
  useIsomorphicLayoutEffect(() => {
188
- if (!isTeleport) {
189
- AdaptPortals.set(portalName, FinalContents)
190
- return () => {
191
- AdaptPortals.delete(portalName)
192
- }
349
+ if (rawActive) {
350
+ setPresent(true)
351
+ } else if (!active) {
352
+ setPresent(false)
353
+ }
354
+
355
+ if (rawActive && exiting) {
356
+ setExiting(false)
193
357
  }
194
- }, [portalName, isTeleport])
358
+ }, [active, exiting, rawActive])
359
+
360
+ const handoff = React.useMemo<AdaptTargetHandoff>(
361
+ () => ({
362
+ hidden: targetHidden,
363
+ skipNextAnimation,
364
+ onTransition(e) {
365
+ // only act on completed transitions; a started or interrupted
366
+ // (finished === false) transition leaves the latch as-is.
367
+ if (e.phase !== 'end' || e.finished === false) return
368
+
369
+ if (e.cause !== 'close') {
370
+ setTargetFullyHidden(false)
371
+ return
372
+ }
373
+
374
+ if (openRef.current && rawActiveRef.current) {
375
+ setTargetFullyHidden(false)
376
+ return
377
+ }
378
+
379
+ setTargetFullyHidden(true)
380
+ releasePresenceLatch()
381
+ },
382
+ }),
383
+ [releasePresenceLatch, skipNextAnimation, targetHidden]
384
+ )
195
385
 
196
- const [when, setWhen] = React.useState<AdaptWhen>(null)
197
- const [platform, setPlatform] = React.useState<AdaptPlatform>(null)
386
+ const registerTarget = React.useCallback(() => {
387
+ targetCountRef.current += 1
388
+
389
+ if (process.env.NODE_ENV === 'development' && targetCountRef.current > 1) {
390
+ console.error(
391
+ `Adapt expected exactly one target in scope "${scope}", but ${targetCountRef.current} targets registered.`
392
+ )
393
+ }
394
+ }, [scope])
395
+
396
+ const unregisterTarget = React.useCallback(() => {
397
+ targetCountRef.current = Math.max(0, targetCountRef.current - 1)
398
+ }, [])
399
+
400
+ const registerContents = React.useCallback(() => {
401
+ contentsCountRef.current += 1
402
+ }, [])
403
+
404
+ const unregisterContents = React.useCallback(() => {
405
+ contentsCountRef.current = Math.max(0, contentsCountRef.current - 1)
406
+ }, [])
407
+
408
+ const registerRenderCallback = React.useCallback(() => {
409
+ renderCallbackCountRef.current += 1
410
+ }, [])
411
+
412
+ const unregisterRenderCallback = React.useCallback(() => {
413
+ renderCallbackCountRef.current = Math.max(0, renderCallbackCountRef.current - 1)
414
+ }, [])
415
+
416
+ React.useEffect(() => {
417
+ if (process.env.NODE_ENV !== 'development') return
418
+ if (!active) return
419
+
420
+ const timer = setTimeout(() => {
421
+ if (
422
+ rawActiveRef.current &&
423
+ targetCountRef.current === 0 &&
424
+ contentsCountRef.current === 0 &&
425
+ renderCallbackCountRef.current === 0
426
+ ) {
427
+ console.error(
428
+ `Adapt is active in scope "${scope}" but no target registered with useAdaptTarget(), Adapt.Contents marker, or render callback consumed it.`
429
+ )
430
+ }
431
+ })
432
+
433
+ return () => clearTimeout(timer)
434
+ }, [active, scope])
435
+
436
+ const FinalContents = Contents || AdaptSlotContents
198
437
 
199
438
  return (
200
- <AdaptChildrenStoreContext.Provider value={childrenStoreRef.current}>
201
- <LastAdaptContextScope.Provider value={scope}>
202
- <ProvideAdaptContext
203
- Contents={FinalContents}
204
- when={when}
205
- platform={platform}
206
- setPlatform={setPlatform}
207
- setWhen={setWhen}
208
- portalName={portalName}
209
- scopeName={scope}
210
- >
211
- {children}
212
- </ProvideAdaptContext>
213
- </LastAdaptContextScope.Provider>
214
- </AdaptChildrenStoreContext.Provider>
439
+ <LastAdaptContextScope.Provider value={scope}>
440
+ <ProvideAdaptContext
441
+ Contents={FinalContents}
442
+ active={active}
443
+ setAdaptConfig={setAdaptConfig}
444
+ portalName={portalName}
445
+ scopeName={scope}
446
+ slot={slotRef.current}
447
+ open={open}
448
+ onOpenChange={onOpenChange}
449
+ state={state}
450
+ handoff={handoff}
451
+ targetFullyHidden={targetFullyHidden}
452
+ registerTarget={registerTarget}
453
+ unregisterTarget={unregisterTarget}
454
+ registerContents={registerContents}
455
+ unregisterContents={unregisterContents}
456
+ registerRenderCallback={registerRenderCallback}
457
+ unregisterRenderCallback={unregisterRenderCallback}
458
+ >
459
+ {children}
460
+ </ProvideAdaptContext>
461
+ </LastAdaptContextScope.Provider>
215
462
  )
216
463
  }
217
464
 
@@ -230,35 +477,56 @@ export const AdaptContents = ({ scope, ...rest }: { scope?: string }) => {
230
477
  )
231
478
  }
232
479
 
480
+ useIsomorphicLayoutEffect(() => {
481
+ if (!context.active) return
482
+
483
+ context.registerContents()
484
+ return () => {
485
+ context.unregisterContents()
486
+ }
487
+ }, [context.active, context.registerContents, context.unregisterContents])
488
+
233
489
  // forwards props - see shouldForwardSpace
234
- return React.createElement(context.Contents, { ...rest, key: `stable` })
490
+ return React.createElement(context.Contents, { ...rest, scope, key: `stable` })
235
491
  }
236
492
 
237
493
  AdaptContents.shouldForwardSpace = true
238
494
 
239
495
  export const Adapt = withStaticProperties(
240
496
  function Adapt(props: AdaptProps) {
241
- const { platform, when, children, scope } = props
497
+ const { children, platform, scope, when } = props
242
498
  const context = useAdaptContext(scope)
243
- const enabled = useAdaptIsActiveGiven(props)
499
+ const enabled = context.active
500
+ const isRenderCallback = typeof children === 'function'
244
501
 
502
+ // only reaches the parent when it could not read this element off its own
503
+ // children, i.e. a userland component renders the <Adapt />
245
504
  useIsomorphicLayoutEffect(() => {
246
- context?.setWhen?.((when || enabled) as AdaptWhen)
247
- context?.setPlatform?.(platform || null)
248
- }, [when, platform, enabled, context.setWhen, context.setPlatform])
505
+ context.setAdaptConfig({ when, platform })
506
+ return () => {
507
+ context.setAdaptConfig(null)
508
+ }
509
+ }, [when, platform, context.setAdaptConfig])
249
510
 
250
511
  useIsomorphicLayoutEffect(() => {
512
+ if (!enabled || !isRenderCallback) return
513
+
514
+ context.registerRenderCallback()
251
515
  return () => {
252
- context?.setWhen?.(null)
253
- context?.setPlatform?.(null)
516
+ context.unregisterRenderCallback()
254
517
  }
255
- }, [])
518
+ }, [
519
+ enabled,
520
+ isRenderCallback,
521
+ context.registerRenderCallback,
522
+ context.unregisterRenderCallback,
523
+ ])
256
524
 
257
525
  let output: React.ReactNode
258
526
 
259
- if (typeof children === 'function') {
527
+ if (isRenderCallback) {
260
528
  const Component = context?.Contents
261
- output = children(Component ? <Component /> : null)
529
+ output = children(Component ? <Component /> : null, getAdaptRenderState(context))
262
530
  } else {
263
531
  output = children
264
532
  }
@@ -273,63 +541,86 @@ export const Adapt = withStaticProperties(
273
541
  export const AdaptPortalContents = (props: {
274
542
  children: React.ReactNode
275
543
  scope?: string
276
- passThrough?: boolean
277
544
  }) => {
278
545
  const isActive = useAdaptIsActive(props.scope)
279
- const { portalName } = useAdaptContext(props.scope)
280
- const childrenStore = useContext(AdaptChildrenStoreContext)
281
- const isTeleport = !isWeb && getPortal().state.type === 'teleport'
282
-
283
- // when teleport is enabled, bypass PortalItem to avoid nested teleport
284
- // (inner portal inside already-teleported Sheet) which breaks touch
285
- // coordinate mapping on iOS. children are passed via external store
286
- // to TeleportAdaptContents rendered at Adapt.Contents.
287
- if (isTeleport && childrenStore) {
288
- return (
289
- <AdaptPortalTeleport isActive={isActive} store={childrenStore}>
290
- {props.children}
291
- </AdaptPortalTeleport>
292
- )
293
- }
546
+ const { slot } = useAdaptContext(props.scope)
294
547
 
295
548
  return (
296
- <PortalItem passThrough={!isActive} hostName={portalName}>
549
+ <AdaptSlotPublisher isActive={isActive} slot={slot}>
297
550
  {props.children}
298
- </PortalItem>
551
+ </AdaptSlotPublisher>
299
552
  )
300
553
  }
301
554
 
302
- function AdaptPortalTeleport({
555
+ function AdaptSlotContents({ scope }: { scope?: string }) {
556
+ const { slot } = useAdaptContext(scope)
557
+
558
+ React.useSyncExternalStore(
559
+ slot?.subscribe ?? emptySubscribe,
560
+ slot?.getSnapshot ?? emptySnapshot,
561
+ slot?.getSnapshot ?? emptySnapshot
562
+ )
563
+
564
+ return <>{slot?.element ?? null}</>
565
+ }
566
+
567
+ function AdaptSlotPublisher({
303
568
  isActive,
304
- store,
569
+ slot,
305
570
  children,
306
571
  }: {
307
572
  isActive: boolean
308
- store: AdaptChildrenStore
573
+ slot: AdaptSlotStore | null
309
574
  children: React.ReactNode
310
575
  }) {
311
- // keep the teleport store in sync without clearing it between parent
312
- // renders. clearing only on real unmount or inactive lets React reconcile
313
- // sheet/dialog/popover contents instead of remounting the subtree.
576
+ const publishedRef = React.useRef<{
577
+ isActive: boolean
578
+ element: React.ReactNode
579
+ } | null>(null)
580
+
581
+ // Publish the live element value after every commit. Do not memoize this handoff
582
+ // on a dep array: stale element deps caused the Sheet overlay-hoist regression
583
+ // this replaces. Comparing against what was actually published is safe, and
584
+ // keeps an unchanged element from re-rendering every slot consumer.
314
585
  useIsomorphicLayoutEffect(() => {
586
+ if (!slot) return
587
+
588
+ const published = publishedRef.current
589
+ if (published && published.isActive === isActive && published.element === children) {
590
+ return
591
+ }
592
+
593
+ publishedRef.current = { isActive, element: children }
594
+
315
595
  if (isActive) {
316
- store.set(children)
596
+ slot.publish(children)
317
597
  } else {
318
- store.set(null)
598
+ slot.clear()
319
599
  }
600
+
601
+ slot.notify()
320
602
  })
603
+
321
604
  useIsomorphicLayoutEffect(() => {
322
- return () => store.set(null)
323
- }, [store])
605
+ return () => {
606
+ if (publishedRef.current?.isActive) {
607
+ slot?.clear()
608
+ slot?.notify()
609
+ }
610
+ publishedRef.current = null
611
+ }
612
+ }, [slot])
324
613
 
325
614
  return isActive ? null : <>{children}</>
326
615
  }
327
616
 
328
- const useAdaptIsActiveGiven = ({
329
- when,
330
- platform,
331
- }: Pick<AdaptProps, 'when' | 'platform'>) => {
617
+ const emptySubscribe = () => () => {}
618
+ const emptySnapshot = () => 0
619
+
620
+ const useAdaptIsActiveGiven = (config: AdaptConfig | null) => {
332
621
  const media = useMedia()
622
+ const when = config?.when
623
+ const platform = config?.platform
333
624
 
334
625
  if (when == null && platform == null) {
335
626
  return false
@@ -359,6 +650,68 @@ const useAdaptIsActiveGiven = ({
359
650
  }
360
651
 
361
652
  export const useAdaptIsActive = (scope?: string) => {
362
- const props = useAdaptContext(scope)
363
- return useAdaptIsActiveGiven(props)
653
+ return useAdaptContext(scope).active
654
+ }
655
+
656
+ export function useAdaptTarget<State = unknown>(
657
+ scope?: string
658
+ ): AdaptTarget<State> | null {
659
+ const context = useAdaptContext(scope)
660
+
661
+ useIsomorphicLayoutEffect(() => {
662
+ if (!context.active) return
663
+
664
+ context.registerTarget()
665
+ return () => {
666
+ context.unregisterTarget()
667
+ }
668
+ }, [context.active, context.registerTarget, context.unregisterTarget])
669
+
670
+ if (!context.active) {
671
+ return null
672
+ }
673
+
674
+ return {
675
+ open: context.open,
676
+ onOpenChange: context.onOpenChange,
677
+ handoff: context.handoff,
678
+ state: context.state as State,
679
+ }
680
+ }
681
+
682
+ export const AdaptCapabilities = ({
683
+ children,
684
+ scroll,
685
+ overlay,
686
+ dismiss,
687
+ }: AdaptCapabilitiesValue & { children?: React.ReactNode }) => {
688
+ const parent = useContext(AdaptCapabilitiesContext)
689
+ const value = React.useMemo(
690
+ () => ({
691
+ scroll: scroll ?? parent.scroll,
692
+ overlay: overlay ?? parent.overlay,
693
+ dismiss: dismiss ?? parent.dismiss,
694
+ }),
695
+ [dismiss, overlay, parent.dismiss, parent.overlay, parent.scroll, scroll]
696
+ )
697
+
698
+ return (
699
+ <AdaptCapabilitiesContext.Provider value={value}>
700
+ {children}
701
+ </AdaptCapabilitiesContext.Provider>
702
+ )
703
+ }
704
+
705
+ export const useAdaptedCapabilities = () => {
706
+ return useContext(AdaptCapabilitiesContext)
707
+ }
708
+
709
+ function getAdaptRenderState(context: AdaptParentContextI): AdaptRenderState {
710
+ return {
711
+ active: context.active,
712
+ open: context.open,
713
+ onOpenChange: context.onOpenChange,
714
+ state: context.state,
715
+ handoff: context.handoff,
716
+ }
364
717
  }