@tamagui/web 3.0.3 → 3.0.4

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.
Files changed (36) hide show
  1. package/dist/cjs/_withNativeStyle.cjs +85 -46
  2. package/dist/cjs/_withNativeStyle.native.cjs +91 -41
  3. package/dist/cjs/_withNativeStyle.native.js +91 -41
  4. package/dist/cjs/_withNativeStyle.native.js.map +1 -1
  5. package/dist/cjs/helpers/nativeStyleEngine.cjs +140 -69
  6. package/dist/cjs/helpers/nativeStyleEngine.native.cjs +177 -118
  7. package/dist/cjs/helpers/nativeStyleEngine.native.js +177 -118
  8. package/dist/cjs/helpers/nativeStyleEngine.native.js.map +1 -1
  9. package/dist/cjs/index.cjs +1 -0
  10. package/dist/cjs/index.native.cjs +1 -0
  11. package/dist/cjs/index.native.js +1 -0
  12. package/dist/cjs/index.native.js.map +1 -1
  13. package/dist/esm/_withNativeStyle.mjs +85 -40
  14. package/dist/esm/_withNativeStyle.mjs.map +1 -1
  15. package/dist/esm/_withNativeStyle.native.js +92 -42
  16. package/dist/esm/_withNativeStyle.native.js.map +1 -1
  17. package/dist/esm/helpers/nativeStyleEngine.mjs +141 -71
  18. package/dist/esm/helpers/nativeStyleEngine.mjs.map +1 -1
  19. package/dist/esm/helpers/nativeStyleEngine.native.js +177 -119
  20. package/dist/esm/helpers/nativeStyleEngine.native.js.map +1 -1
  21. package/dist/esm/index.js +2 -2
  22. package/dist/esm/index.mjs +2 -2
  23. package/dist/esm/index.native.js +2 -2
  24. package/package.json +17 -17
  25. package/src/_withNativeStyle.tsx +141 -55
  26. package/src/helpers/nativeStyleEngine.ts +230 -101
  27. package/src/index.ts +1 -0
  28. package/src/setupHooks.ts +1 -0
  29. package/types/_withNativeStyle.d.ts +22 -2
  30. package/types/_withNativeStyle.d.ts.map +1 -1
  31. package/types/helpers/nativeStyleEngine.d.ts +24 -9
  32. package/types/helpers/nativeStyleEngine.d.ts.map +1 -1
  33. package/types/index.d.ts +1 -1
  34. package/types/index.d.ts.map +1 -1
  35. package/types/setupHooks.d.ts +1 -0
  36. package/types/setupHooks.d.ts.map +1 -1
@@ -1,20 +1,47 @@
1
1
  import { setRef } from '@tamagui/compose-refs'
2
- import React, { useCallback, useContext, useRef, useState } from 'react'
2
+ import { isAndroid, isIos } from '@tamagui/constants'
3
+ import React, { use } from 'react'
3
4
  import { _withStableStyle } from './_withStableStyle'
4
5
  import {
5
6
  getNativeStyleEngine,
6
7
  linkNativeStyleMapping,
7
8
  resolveNativeStyleMapping,
8
- type NativeStyleEngineLinkHandle,
9
9
  type NativeStyleThemeMapping,
10
10
  } from './helpers/nativeStyleEngine'
11
11
  import { getThemeState, ThemeStateContext } from './hooks/useThemeState'
12
+ import { hooks } from './setupHooks'
12
13
 
13
- /** internal: emitted by the native compiler when experimental.nativeFastPath is on */
14
+ // the host props RN's <Text> passes for a non-pressable text outside a text
15
+ // ancestor, given none of the props it remaps (the compiler guarantees that)
16
+ const hostTextProps = {
17
+ accessible: isAndroid ? false : isIos ? true : undefined,
18
+ allowFontScaling: true,
19
+ ellipsizeMode: 'tail',
20
+ }
21
+
22
+ /**
23
+ * internal: emitted by the native compiler when experimental.nativeFastPath is on.
24
+ *
25
+ * One component over the host: the theme scope is the only context read, the
26
+ * host ref links straight to the engine and unlinks through React's ref
27
+ * cleanup, so a themed view costs one component and one engine call to mount.
28
+ *
29
+ * `host: 'view'`: `Component` is RN's host view (`unstable_NativeView`).
30
+ * <View> resets TextAncestorContext for its children when it sits inside a
31
+ * <Text>; this wrapper does that reset itself, so the host view can carry
32
+ * children without changing what a nested <Text> renders.
33
+ *
34
+ * `host: 'text'`: `Component` is RN's <Text>, and outside a text ancestor this
35
+ * renders its host (`unstable_NativeText`) with the exact props <Text> would
36
+ * pass: the defaults above, `overflow: 'hidden'` first in the style, a string
37
+ * fontWeight, and TextAncestorContext only around element children. Inside a
38
+ * text ancestor it renders <Text>, which owns the virtual text path.
39
+ */
14
40
  export const _withNativeStyle = (
15
41
  Component: any,
16
42
  baseStyle: Record<string, unknown>,
17
- themeStyleKeys: NativeStyleThemeMapping
43
+ themeStyleKeys: NativeStyleThemeMapping,
44
+ host?: 'view' | 'text'
18
45
  ) => {
19
46
  const Fallback = _withStableStyle(
20
47
  Component,
@@ -28,62 +55,121 @@ export const _withNativeStyle = (
28
55
  true,
29
56
  false
30
57
  )
58
+ const baseViews = host ? hooks.getBaseViews?.() : undefined
59
+ const TextAncestor: React.Context<boolean> | undefined = baseViews?.TextAncestor
60
+ const TextAncestorProvider = TextAncestor?.Provider as React.Provider<boolean>
61
+ // <Text> rewrites userSelect and verticalAlign into other props
62
+ const HostText =
63
+ host === 'text' && !('userSelect' in baseStyle) && !('verticalAlign' in baseStyle)
64
+ ? baseViews?.NativeText
65
+ : undefined
66
+ const hostTextBase = HostText
67
+ ? {
68
+ overflow: 'hidden',
69
+ ...baseStyle,
70
+ ...(typeof baseStyle.fontWeight === 'number' && {
71
+ fontWeight: String(baseStyle.fontWeight),
72
+ }),
73
+ }
74
+ : baseStyle
75
+ // one style array per scope and host mode, its theme slot set to the scope's
76
+ // current state on every render. the engine owns a linked view's theme
77
+ // values, so a re-render after a switch hands React the same array, its diff
78
+ // skips the style, and the host is not re-committed with values the engine
79
+ // already applied. a new host reads the array's current state at creation
80
+ const styles = new Map<string, (object | null)[]>()
81
+ const hostTextStyles = new Map<string, (object | null)[]>()
82
+ // bundlers inline imports as a module lookup at every use; bind them once
83
+ // here, outside the per-mount path
84
+ const ScopeContext = ThemeStateContext
85
+ const engineOf = getNativeStyleEngine
86
+ const scopeState = getThemeState
87
+ const resolve = resolveNativeStyleMapping
88
+ const linkMapping = linkNativeStyleMapping
89
+ const assignRef = setRef
31
90
 
32
- const Native = React.memo(function NativeStyleView({
33
- _scopeId,
34
- ...props
35
- }: {
36
- _scopeId: string
37
- [key: string]: any
38
- }) {
39
- const { ref, _expressions: _ignoredExpressions, ...rest } = props
40
- const link = useRef<NativeStyleEngineLinkHandle | null>(null)
41
- // a host the engine refuses (a nitro view) never gets theme pushes, so
42
- // this view renders through the theme-subscribed fallback instead
43
- const [refused, setRefused] = useState(false)
44
- const themeState = getThemeState(_scopeId)
45
- const stateName = themeState?.name
46
- const theme = themeState?.theme
47
- const hostRef = useCallback(
48
- (host: unknown) => {
49
- link.current?.unlink()
50
- link.current = null
51
- setRef(ref, host)
52
- if (host && stateName && theme) {
53
- link.current = linkNativeStyleMapping(
54
- host,
55
- baseStyle,
56
- themeStyleKeys,
57
- _scopeId,
58
- stateName,
59
- theme
60
- )
61
- if (!link.current) setRefused(true)
62
- }
63
- },
64
- [ref, _scopeId, stateName, theme]
65
- )
66
- if (refused) return <Fallback {...props} />
67
- const stateStyle =
68
- stateName && theme
69
- ? resolveNativeStyleMapping(themeStyleKeys, stateName, theme)
70
- : undefined
91
+ // `use` reads context from whichever render calls it, so the compiler can
92
+ // call this straight from the parent component's render (see `inline`)
93
+ const render = (props: any, key?: React.Key | null) => {
94
+ const scopeId = use(ScopeContext)
95
+ const inText = TextAncestor ? use(TextAncestor) : false
96
+ const themeState = scopeId && engineOf() ? scopeState(scopeId) : undefined
97
+ const resetsText = inText && host === 'view'
71
98
 
72
- if (process.env.NODE_ENV === 'development' && (!stateName || !theme)) {
73
- throw new Error(
74
- `[@tamagui/core] native fast path cannot resolve theme ${JSON.stringify(stateName)}`
99
+ if (!themeState) {
100
+ return resetsText ? (
101
+ <TextAncestorProvider key={key} value={false}>
102
+ <Fallback {...props} />
103
+ </TextAncestorProvider>
104
+ ) : (
105
+ <Fallback key={key} {...props} />
75
106
  )
76
107
  }
77
108
 
78
- return <Component ref={hostRef} style={[baseStyle, stateStyle]} {...rest} />
79
- })
109
+ // compiled createElement calls carry an `_expressions` prop for the
110
+ // fallback; jsx never does, so it forwards its props without a copy
111
+ let rest = props
112
+ if (props._expressions !== undefined) {
113
+ const { _expressions: _ignoredExpressions, ...withoutExpressions } = props
114
+ rest = withoutExpressions
115
+ }
116
+ const ref = props.ref
117
+ const { name, theme } = themeState
118
+ const asHostText = HostText && !inText
119
+ const Rendered = asHostText ? HostText : Component
120
+ const cache = asHostText ? hostTextStyles : styles
121
+ let style = cache.get(scopeId)
122
+ if (!style) {
123
+ style = [asHostText ? hostTextBase : baseStyle, null]
124
+ cache.set(scopeId, style)
125
+ }
126
+ style[1] = resolve(themeStyleKeys, name, theme)
127
+ const wrapped = resetsText || (asHostText && mayRenderText(rest.children))
128
+ const element = (
129
+ <Rendered
130
+ key={wrapped ? undefined : key}
131
+ {...(asHostText && hostTextProps)}
132
+ {...rest}
133
+ ref={(node: unknown) => {
134
+ // a callback ref may return its own cleanup (React 19), which then
135
+ // replaces the null call
136
+ let refCleanup: unknown
137
+ if (typeof ref === 'function') refCleanup = ref(node)
138
+ else assignRef(ref, node)
139
+ const link = linkMapping(node, themeStyleKeys, scopeId, name, theme)
140
+ return () => {
141
+ link?.unlink()
142
+ if (typeof refCleanup === 'function') refCleanup()
143
+ else assignRef(ref, null)
144
+ }
145
+ }}
146
+ style={style}
147
+ />
148
+ )
80
149
 
81
- return React.memo(function NativeStyleBoundary(props: any) {
82
- const scopeId = useContext(ThemeStateContext)
83
- return getNativeStyleEngine() && scopeId ? (
84
- <Native {...props} _scopeId={scopeId} />
85
- ) : (
86
- <Fallback {...props} />
150
+ if (!wrapped) return element
151
+ return (
152
+ <TextAncestorProvider key={key} value={!resetsText}>
153
+ {element}
154
+ </TextAncestorProvider>
87
155
  )
88
- })
156
+ }
157
+
158
+ const NativeStyleView = (props: any) => render(props)
159
+ // the compiler emits `View.inline(<View ... />)` where the calling
160
+ // component's theme scope is provably the element's own: the element is
161
+ // only built, never mounted, and its host renders in the caller's place
162
+ NativeStyleView.inline = (element: React.ReactElement<any>) =>
163
+ render(element.props, element.key)
164
+ return NativeStyleView
165
+ }
166
+
167
+ // <Text>'s own check for children that could render a nested <Text>: arrays
168
+ // longer than three are assumed to
169
+ function mayRenderText(children: unknown): boolean {
170
+ if (children == null) return false
171
+ if (Array.isArray(children) && children.length <= 3) {
172
+ return children.some((child) => child != null && typeof child === 'object')
173
+ }
174
+ return typeof children === 'object'
89
175
  }
@@ -86,27 +86,45 @@ export interface NativeViewStateUpdate {
86
86
  props?: Record<string, unknown>
87
87
  }
88
88
 
89
- export interface NativeViewStateTableUpdate {
90
- id: number
91
- state: string
92
- props: Record<string, unknown>
93
- }
94
-
95
89
  export interface NativeStyleEngineSlots {
96
- base?: Record<string, unknown>
97
90
  state?: Record<string, Record<string, unknown>>
98
91
  }
99
92
 
100
93
  export type NativeStyleThemeMapping = Record<string, string>
101
94
 
102
95
  export interface NativeStyleEngine {
96
+ /**
97
+ * links a mounted host. `table` is a shared table id from setTableState
98
+ * (compiler mappings) or per-view slots (runtime views driven through
99
+ * applyViewStates). `commitNow` commits the table's active state for the
100
+ * view once, for a host whose render raced a scope change.
101
+ */
103
102
  link(
104
103
  ref: unknown,
105
- slots: NativeStyleEngineSlots,
106
- scopeId?: string
104
+ table: NativeStyleEngineSlots | number,
105
+ scopeId?: string,
106
+ commitNow?: boolean
107
107
  ): NativeStyleEngineLinkHandle | null
108
+ /**
109
+ * links many mounted hosts to shared tables in one call: entry i links
110
+ * refs[i] to tableIds[i] under scopes[scopeIndexes[i]]. returns the id of
111
+ * entry 0; ids are consecutive, and an entry whose host is gone consumes
112
+ * its id unlinked
113
+ */
114
+ linkTables(
115
+ refs: unknown[],
116
+ tableIds: number[],
117
+ scopeIndexes: number[],
118
+ scopes: string[]
119
+ ): number
120
+ /** unlinks an id from linkTables */
121
+ unlink(id: number): void
122
+ /**
123
+ * fills one state of a shared table, read by every view linked to it;
124
+ * views already showing that state commit the new props
125
+ */
126
+ setTableState(tableId: number, state: string, props: Record<string, unknown>): void
108
127
  applyViewStates(entries: NativeViewStateUpdate[]): void
109
- updateViewStateTables(entries: NativeViewStateTableUpdate[]): void
110
128
  processStyleColors(props: Record<string, unknown>): Record<string, unknown>
111
129
  setStateName(stateName: string, scopeId?: string): void
112
130
  removeScope(scopeId: string): void
@@ -115,12 +133,6 @@ export interface NativeStyleEngine {
115
133
  let engine: NativeStyleEngine | null = null
116
134
  const scopeStates = new Map<string, { name: string; theme: object }>()
117
135
 
118
- interface CompiledMappingLink {
119
- handle: NativeStyleEngineLinkHandle
120
- scopeId: string
121
- stateThemes: Map<string, object>
122
- }
123
-
124
136
  interface ResolvedMappingState {
125
137
  theme: object
126
138
  /** raw resolved values, what React renders through RN's own style normalizer */
@@ -132,17 +144,19 @@ interface ResolvedMappingState {
132
144
  interface CompiledMapping {
133
145
  mapping: NativeStyleThemeMapping
134
146
  states: Map<string, ResolvedMappingState>
135
- links: Set<CompiledMappingLink>
147
+ /** the engine table every view of this mapping links to */
148
+ tableId: number
149
+ /** the resolved state last sent to the engine table, per state name */
150
+ sent: Map<string, ResolvedMappingState>
136
151
  }
137
152
 
138
153
  const mappingKeys = new WeakMap<object, string>()
139
154
  const compiledMappings = new Map<string, CompiledMapping>()
140
- // linked mappings remain update owners across memo-generation rollovers
141
- const activeCompiledMappings = new Set<CompiledMapping>()
142
- const processedBases = new WeakMap<
143
- object,
144
- { engine: NativeStyleEngine; props: Record<string, unknown> }
145
- >()
155
+ // linked mappings per scope, with their live link counts. they remain update
156
+ // owners across memo-generation rollovers, and a scope change only syncs the
157
+ // mappings linked under that scope
158
+ const scopeMappings = new Map<string, Map<CompiledMapping, number>>()
159
+ let nextTableId = 1
146
160
 
147
161
  function getCompiledMapping(mapping: NativeStyleThemeMapping): CompiledMapping {
148
162
  let key = mappingKeys.get(mapping)
@@ -156,7 +170,12 @@ function getCompiledMapping(mapping: NativeStyleThemeMapping): CompiledMapping {
156
170
  }
157
171
  let compiled = compiledMappings.get(key)
158
172
  if (!compiled) {
159
- compiled = { mapping, states: new Map(), links: new Set() }
173
+ compiled = {
174
+ mapping,
175
+ states: new Map(),
176
+ tableId: nextTableId++,
177
+ sent: new Map(),
178
+ }
160
179
  if (compiledMappings.size >= 10_000) {
161
180
  compiledMappings.clear()
162
181
  }
@@ -184,19 +203,31 @@ function resolveCompiledMapping(
184
203
  const nativeProps = engine?.processStyleColors(renderProps) ?? renderProps
185
204
  if (compiled.states.size >= 10_000) {
186
205
  compiled.states.clear()
187
- for (const link of compiled.links) {
188
- link.stateThemes.clear()
189
- }
190
206
  }
191
207
  const resolved: ResolvedMappingState = { theme, renderProps, nativeProps }
192
208
  compiled.states.set(stateName, resolved)
193
209
  return resolved
194
210
  }
195
211
 
212
+ /** resolves one state of a mapping and sends it to the engine table once */
213
+ function syncTableState(
214
+ compiled: CompiledMapping,
215
+ stateName: string,
216
+ theme: Record<string, unknown>
217
+ ): void {
218
+ const resolved = resolveCompiledMapping(compiled, stateName, theme)
219
+ if (compiled.sent.get(stateName) === resolved) return
220
+ compiled.sent.set(stateName, resolved)
221
+ engine!.setTableState(compiled.tableId, stateName, resolved.nativeProps)
222
+ }
223
+
196
224
  export function setNativeStyleEngine(next: NativeStyleEngine | null): void {
197
225
  if (engine !== next) {
226
+ settleReleases()
198
227
  compiledMappings.clear()
199
- activeCompiledMappings.clear()
228
+ scopeMappings.clear()
229
+ pendingLinks = []
230
+ unlinkedPending = 0
200
231
  }
201
232
  engine = next
202
233
  if (next) {
@@ -217,26 +248,19 @@ export function updateNativeStyleScope(
217
248
  ): void {
218
249
  const previous = scopeStates.get(scopeId)
219
250
  if (previous?.name === stateName && previous.theme === theme) return
251
+ // pending views rendered the outgoing state: link them before it changes
252
+ flushNativeStyleLinks()
220
253
  scopeStates.set(scopeId, { name: stateName, theme })
221
254
 
222
255
  if (engine) {
223
- const entries: NativeViewStateTableUpdate[] = []
224
- for (const compiled of activeCompiledMappings) {
225
- let props: Record<string, unknown> | undefined
226
- for (const link of compiled.links) {
227
- if (link.scopeId !== scopeId) continue
228
- const linkedTheme = link.stateThemes.get(stateName)
229
- if (linkedTheme === theme) continue
230
- props ||= resolveCompiledMapping(
231
- compiled,
232
- stateName,
233
- theme as Record<string, unknown>
234
- ).nativeProps
235
- entries.push({ id: link.handle.id, state: stateName, props })
236
- link.stateThemes.set(stateName, theme)
256
+ // one table entry per mapping, never per view: a state name resolves to
257
+ // one theme, so every scope showing it reads the same table entry
258
+ const linked = scopeMappings.get(scopeId)
259
+ if (linked) {
260
+ for (const compiled of linked.keys()) {
261
+ syncTableState(compiled, stateName, theme as Record<string, unknown>)
237
262
  }
238
263
  }
239
- if (entries.length) engine.updateViewStateTables(entries)
240
264
  }
241
265
  engine?.setStateName(stateName, scopeId)
242
266
  }
@@ -254,9 +278,112 @@ export function resolveNativeStyleMapping(
254
278
  return resolveCompiledMapping(getCompiledMapping(mapping), stateName, theme).renderProps
255
279
  }
256
280
 
281
+ interface MappingLink {
282
+ ref: unknown
283
+ compiled: CompiledMapping
284
+ scopeId: string
285
+ /** 0 while pending, -1 once unlinked */
286
+ id: number
287
+ /** the engine that assigned `id`: a later engine swap must not receive its unlink */
288
+ owner: NativeStyleEngine | null
289
+ }
290
+
291
+ // views link lazily: a mount only records its host here, and the engine
292
+ // links every pending view in one call before a scope next changes. linking
293
+ // exists for theme switches, so a mount pays nothing native, and a view that
294
+ // unmounts before any switch never reaches the engine
295
+ let pendingLinks: MappingLink[] = []
296
+ // pending entries unlinked before any flush; compacted once they dominate
297
+ let unlinkedPending = 0
298
+
299
+ function trackScopeMapping(link: MappingLink, delta: 1 | -1) {
300
+ let linked = scopeMappings.get(link.scopeId)
301
+ if (!linked) scopeMappings.set(link.scopeId, (linked = new Map()))
302
+ const count = (linked.get(link.compiled) ?? 0) + delta
303
+ if (count) {
304
+ linked.set(link.compiled, count)
305
+ } else {
306
+ linked.delete(link.compiled)
307
+ if (!linked.size) scopeMappings.delete(link.scopeId)
308
+ }
309
+ }
310
+
311
+ /** links every pending view in one engine call */
312
+ export function flushNativeStyleLinks(): void {
313
+ settleReleases()
314
+ if (!pendingLinks.length) return
315
+ const links = pendingLinks
316
+ pendingLinks = []
317
+ unlinkedPending = 0
318
+ if (!engine) return
319
+ const live = links.filter((link) => link.id === 0)
320
+ if (!live.length) return
321
+ const refs: unknown[] = []
322
+ const tableIds: number[] = []
323
+ const scopeIndexes: number[] = []
324
+ const scopes: string[] = []
325
+ for (const link of live) {
326
+ let scopeIndex = scopes.indexOf(link.scopeId)
327
+ if (scopeIndex === -1) scopeIndex = scopes.push(link.scopeId) - 1
328
+ refs.push(link.ref)
329
+ tableIds.push(link.compiled.tableId)
330
+ scopeIndexes.push(scopeIndex)
331
+ }
332
+ const firstId = engine.linkTables(refs, tableIds, scopeIndexes, scopes)
333
+ for (let i = 0; i < live.length; i++) {
334
+ const link = live[i]!
335
+ link.id = firstId + i
336
+ link.owner = engine
337
+ link.ref = null
338
+ trackScopeMapping(link, 1)
339
+ }
340
+ }
341
+
342
+ // React detaches and re-attaches a callback ref whenever the callback changes,
343
+ // which a parent re-render does to every child. a release settles after the
344
+ // commit, so the same host attaching again keeps its link and the engine keeps
345
+ // its per-view caches instead of unlinking and relinking every view
346
+ interface Release {
347
+ compiled: CompiledMapping
348
+ scopeId: string
349
+ handle: NativeStyleEngineLinkHandle
350
+ settle: () => void
351
+ }
352
+ const releases = new Map<unknown, Release>()
353
+ let settleQueued = false
354
+
355
+ function settleReleases() {
356
+ settleQueued = false
357
+ if (!releases.size) return
358
+ const settling = [...releases.values()]
359
+ releases.clear()
360
+ for (const release of settling) release.settle()
361
+ }
362
+
363
+ function releasable(
364
+ ref: unknown,
365
+ compiled: CompiledMapping,
366
+ scopeId: string,
367
+ id: number,
368
+ settle: () => void
369
+ ): NativeStyleEngineLinkHandle {
370
+ const handle: NativeStyleEngineLinkHandle = {
371
+ id,
372
+ unlink: () => {
373
+ const prior = releases.get(ref)
374
+ if (prior && prior.handle !== handle) prior.settle()
375
+ releases.set(ref, { compiled, scopeId, handle, settle })
376
+ if (!settleQueued) {
377
+ settleQueued = true
378
+ queueMicrotask(settleReleases)
379
+ }
380
+ },
381
+ }
382
+ return handle
383
+ }
384
+
257
385
  export function linkNativeStyleMapping(
258
386
  ref: unknown,
259
- baseStyle: Record<string, unknown>,
260
387
  mapping: NativeStyleThemeMapping,
261
388
  scopeId: string,
262
389
  stateName: string,
@@ -266,71 +393,73 @@ export function linkNativeStyleMapping(
266
393
 
267
394
  const compiled = getCompiledMapping(mapping)
268
395
  // a scope can advance (layout effect) after a concurrent render captured its
269
- // theme but before that render's host ref links. the engine's link() only
270
- // stores tables, it never commits, so link against the live scope state and
271
- // commit that table once to correct the stale React commit.
396
+ // theme but before that render's host ref links. link that host now against
397
+ // the live scope state and have the engine commit it once to correct the
398
+ // stale React commit.
272
399
  const scopeState = scopeStates.get(scopeId)
273
- const currentStateName = scopeState?.name ?? stateName
274
- const currentTheme = (scopeState?.theme ?? theme) as Record<string, unknown>
275
- const stale = currentStateName !== stateName || currentTheme !== theme
276
- const stateProps = resolveCompiledMapping(
277
- compiled,
278
- currentStateName,
279
- currentTheme
280
- ).nativeProps
281
- let base = processedBases.get(baseStyle)
282
- if (!base || base.engine !== engine) {
283
- base = {
284
- engine,
285
- props: engine.processStyleColors(normalizeNativeStyle(baseStyle)),
286
- }
287
- processedBases.set(baseStyle, base)
288
- }
289
- const state: Record<string, Record<string, unknown>> = {}
290
- const stateThemes = new Map<string, object>()
291
- for (const [name, cached] of compiled.states) {
292
- state[name] = cached.nativeProps
293
- stateThemes.set(name, cached.theme)
400
+ const stale =
401
+ scopeState && (scopeState.name !== stateName || scopeState.theme !== theme)
402
+ const kept = releases.get(ref)
403
+ if (kept) {
404
+ releases.delete(ref)
405
+ if (!stale && kept.compiled === compiled && kept.scopeId === scopeId)
406
+ return kept.handle
407
+ kept.settle()
294
408
  }
295
- state[currentStateName] = stateProps
296
- stateThemes.set(currentStateName, currentTheme)
297
- const handle = engine.link(ref, { base: base.props, state }, scopeId)
298
- if (!handle) return null
299
-
300
409
  if (stale) {
301
- engine.updateViewStateTables([
302
- { id: handle.id, state: currentStateName, props: stateProps },
303
- ])
304
- }
305
-
306
- const link: CompiledMappingLink = {
307
- handle,
308
- scopeId,
309
- stateThemes,
310
- }
311
- compiled.links.add(link)
312
- activeCompiledMappings.add(compiled)
313
- return {
314
- id: handle.id,
315
- unlink: () => {
316
- compiled.links.delete(link)
317
- if (!compiled.links.size) {
318
- activeCompiledMappings.delete(compiled)
319
- }
410
+ syncTableState(compiled, scopeState.name, scopeState.theme as Record<string, unknown>)
411
+ const handle = engine.link(ref, compiled.tableId, scopeId, true)
412
+ if (!handle) return null
413
+ const link: MappingLink = {
414
+ ref: null,
415
+ compiled,
416
+ scopeId,
417
+ id: handle.id,
418
+ owner: engine,
419
+ }
420
+ trackScopeMapping(link, 1)
421
+ return releasable(ref, compiled, scopeId, handle.id, () => {
422
+ if (link.id === -1) return
423
+ link.id = -1
424
+ if (link.owner === engine) trackScopeMapping(link, -1)
320
425
  handle.unlink()
321
- },
426
+ })
322
427
  }
428
+
429
+ // the table must hold the state this view rendered before a switch links it
430
+ syncTableState(compiled, stateName, theme)
431
+ const link: MappingLink = { ref, compiled, scopeId, id: 0, owner: null }
432
+ pendingLinks.push(link)
433
+ return releasable(ref, compiled, scopeId, 0, () => {
434
+ const id = link.id
435
+ if (id === -1) return
436
+ link.id = -1
437
+ link.ref = null
438
+ if (id > 0) {
439
+ // scope accounting was reset when the engine changed
440
+ if (link.owner === engine) trackScopeMapping(link, -1)
441
+ link.owner!.unlink(id)
442
+ } else if (++unlinkedPending > 512 && unlinkedPending * 2 > pendingLinks.length) {
443
+ pendingLinks = pendingLinks.filter((pending) => pending.id === 0)
444
+ unlinkedPending = 0
445
+ }
446
+ })
323
447
  }
324
448
 
325
449
  /** memo-generation and live-owner sizes for development diagnostics and probes */
326
- export const getNativeStyleEngineCacheStats = () => ({
327
- mappings: compiledMappings.size,
328
- activeMappings: activeCompiledMappings.size,
329
- states: [...new Set([...compiledMappings.values(), ...activeCompiledMappings])].reduce(
330
- (total, compiled) => total + compiled.states.size,
331
- 0
332
- ),
333
- })
450
+ export const getNativeStyleEngineCacheStats = () => {
451
+ const active = new Set(
452
+ [...scopeMappings.values()].flatMap((linked) => [...linked.keys()])
453
+ )
454
+ return {
455
+ mappings: compiledMappings.size,
456
+ activeMappings: active.size,
457
+ states: [...new Set([...compiledMappings.values(), ...active])].reduce(
458
+ (total, compiled) => total + compiled.states.size,
459
+ 0
460
+ ),
461
+ }
462
+ }
334
463
 
335
464
  let pending: NativeViewStateUpdate[] | null = null
336
465
  let flushListener: ((entries: NativeViewStateUpdate[]) => void) | null = null
package/src/index.ts CHANGED
@@ -23,6 +23,7 @@ export {
23
23
  setNativeStyleEngine,
24
24
  getNativeStyleEngine,
25
25
  setNativeStyleEngineFlushListener,
26
+ flushNativeStyleLinks,
26
27
  updateNativeStyleScope,
27
28
  removeNativeStyleScope,
28
29
  type NativeStyleEngine,
package/src/setupHooks.ts CHANGED
@@ -31,5 +31,6 @@ type InternalHooks = {
31
31
  View: any
32
32
  Text: any
33
33
  TextAncestor: any
34
+ NativeText?: any
34
35
  }
35
36
  }