@furystack/shades 12.3.0 → 12.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,12 @@
1
1
  import { Shade } from '../shade.js'
2
+ import type { ViewTransitionConfig } from '../view-transition.js'
3
+ import { maybeViewTransition } from '../view-transition.js'
2
4
 
3
5
  export interface LazyLoadProps {
4
6
  loader: JSX.Element
5
7
  error?: (error: unknown, retry: () => Promise<void>) => JSX.Element
6
8
  component: () => Promise<JSX.Element>
9
+ viewTransition?: boolean | ViewTransitionConfig
7
10
  }
8
11
 
9
12
  export const LazyLoad = Shade<LazyLoadProps>({
@@ -36,8 +39,10 @@ export const LazyLoad = Shade<LazyLoadProps>({
36
39
  factory()
37
40
  .then((loaded) => {
38
41
  if (tracker.active && tracker.factory === factory) {
39
- setError(undefined)
40
- setComponent(loaded)
42
+ void maybeViewTransition(props.viewTransition, () => {
43
+ setError(undefined)
44
+ setComponent(loaded)
45
+ })
41
46
  }
42
47
  })
43
48
  .catch((err: unknown) => {
@@ -60,7 +65,9 @@ export const LazyLoad = Shade<LazyLoadProps>({
60
65
  setComponent(undefined)
61
66
  const loaded = await factory()
62
67
  if (tracker.active && tracker.factory === factory) {
63
- setComponent(loaded)
68
+ void maybeViewTransition(props.viewTransition, () => {
69
+ setComponent(loaded)
70
+ })
64
71
  }
65
72
  } catch (e) {
66
73
  if (tracker.active && tracker.factory === factory) {
@@ -10,6 +10,7 @@ import {
10
10
  findDivergenceIndex,
11
11
  NestedRouter,
12
12
  renderMatchChain,
13
+ resolveViewTransition,
13
14
  type MatchChainEntry,
14
15
  type NestedRoute,
15
16
  } from './nested-router.js'
@@ -1024,3 +1025,251 @@ describe('NestedRouter + RouteMatchService integration', () => {
1024
1025
  })
1025
1026
  })
1026
1027
  })
1028
+
1029
+ describe('resolveViewTransition', () => {
1030
+ const makeEntry = (viewTransition?: boolean | { types?: string[] }): MatchChainEntry => ({
1031
+ route: { component: () => <div />, viewTransition },
1032
+ match: { path: '/', params: {} },
1033
+ })
1034
+
1035
+ it('should return false when router config is undefined and route has no override', () => {
1036
+ expect(resolveViewTransition(undefined, [makeEntry()])).toBe(false)
1037
+ })
1038
+
1039
+ it('should return false when router config is false', () => {
1040
+ expect(resolveViewTransition(false, [makeEntry()])).toBe(false)
1041
+ })
1042
+
1043
+ it('should return config when router config is true', () => {
1044
+ expect(resolveViewTransition(true, [makeEntry()])).toEqual({ types: undefined })
1045
+ })
1046
+
1047
+ it('should return false when router is true but leaf route opts out', () => {
1048
+ expect(resolveViewTransition(true, [makeEntry(false)])).toBe(false)
1049
+ })
1050
+
1051
+ it('should use router-level types when route has no override', () => {
1052
+ expect(resolveViewTransition({ types: ['slide'] }, [makeEntry()])).toEqual({ types: ['slide'] })
1053
+ })
1054
+
1055
+ it('should prefer route-level types over router-level types', () => {
1056
+ expect(resolveViewTransition({ types: ['slide'] }, [makeEntry({ types: ['fade'] })])).toEqual({
1057
+ types: ['fade'],
1058
+ })
1059
+ })
1060
+
1061
+ it('should enable transitions when only the leaf route enables it', () => {
1062
+ expect(resolveViewTransition(undefined, [makeEntry(true)])).toEqual({ types: undefined })
1063
+ })
1064
+
1065
+ it('should use types from the innermost (leaf) route in a chain', () => {
1066
+ const parent = makeEntry({ types: ['parent-type'] })
1067
+ const child = makeEntry({ types: ['child-type'] })
1068
+ expect(resolveViewTransition(true, [parent, child])).toEqual({ types: ['child-type'] })
1069
+ })
1070
+ })
1071
+
1072
+ describe('NestedRouter view transitions', () => {
1073
+ let startViewTransitionSpy: ReturnType<typeof vi.fn>
1074
+
1075
+ beforeEach(() => {
1076
+ document.body.innerHTML = '<div id="root"></div>'
1077
+ startViewTransitionSpy = vi.fn((optionsOrCallback: StartViewTransitionOptions | (() => void)) => {
1078
+ const update = typeof optionsOrCallback === 'function' ? optionsOrCallback : optionsOrCallback.update
1079
+ update?.()
1080
+ return {
1081
+ finished: Promise.resolve(),
1082
+ ready: Promise.resolve(),
1083
+ updateCallbackDone: Promise.resolve(),
1084
+ skipTransition: vi.fn(),
1085
+ } as unknown as ViewTransition
1086
+ })
1087
+ document.startViewTransition = startViewTransitionSpy as typeof document.startViewTransition
1088
+ })
1089
+
1090
+ afterEach(() => {
1091
+ document.body.innerHTML = ''
1092
+ delete (document as unknown as Record<string, unknown>).startViewTransition
1093
+ })
1094
+
1095
+ it('should call startViewTransition when viewTransition is enabled', async () => {
1096
+ history.pushState(null, '', '/')
1097
+
1098
+ await usingAsync(new Injector(), async (injector) => {
1099
+ const rootElement = document.getElementById('root') as HTMLDivElement
1100
+
1101
+ initializeShadeRoot({
1102
+ injector,
1103
+ rootElement,
1104
+ jsxElement: (
1105
+ <div>
1106
+ <NestedRouteLink id="go-about" href="/about">
1107
+ about
1108
+ </NestedRouteLink>
1109
+ <NestedRouter
1110
+ viewTransition
1111
+ routes={{
1112
+ '/about': { component: () => <div id="content">about</div> },
1113
+ '/': { component: () => <div id="content">home</div> },
1114
+ }}
1115
+ />
1116
+ </div>
1117
+ ),
1118
+ })
1119
+
1120
+ await flushUpdates()
1121
+ startViewTransitionSpy.mockClear()
1122
+
1123
+ document.getElementById('go-about')?.click()
1124
+ await flushUpdates()
1125
+
1126
+ expect(startViewTransitionSpy).toHaveBeenCalledTimes(1)
1127
+ expect(document.getElementById('content')?.innerHTML).toBe('about')
1128
+ })
1129
+ })
1130
+
1131
+ it('should not call startViewTransition when viewTransition is not set', async () => {
1132
+ history.pushState(null, '', '/')
1133
+
1134
+ await usingAsync(new Injector(), async (injector) => {
1135
+ const rootElement = document.getElementById('root') as HTMLDivElement
1136
+
1137
+ initializeShadeRoot({
1138
+ injector,
1139
+ rootElement,
1140
+ jsxElement: (
1141
+ <div>
1142
+ <NestedRouteLink id="go-about" href="/about">
1143
+ about
1144
+ </NestedRouteLink>
1145
+ <NestedRouter
1146
+ routes={{
1147
+ '/about': { component: () => <div id="content">about</div> },
1148
+ '/': { component: () => <div id="content">home</div> },
1149
+ }}
1150
+ />
1151
+ </div>
1152
+ ),
1153
+ })
1154
+
1155
+ await flushUpdates()
1156
+ startViewTransitionSpy.mockClear()
1157
+
1158
+ document.getElementById('go-about')?.click()
1159
+ await flushUpdates()
1160
+
1161
+ expect(startViewTransitionSpy).not.toHaveBeenCalled()
1162
+ expect(document.getElementById('content')?.innerHTML).toBe('about')
1163
+ })
1164
+ })
1165
+
1166
+ it('should pass types to startViewTransition when configured', async () => {
1167
+ history.pushState(null, '', '/')
1168
+
1169
+ await usingAsync(new Injector(), async (injector) => {
1170
+ const rootElement = document.getElementById('root') as HTMLDivElement
1171
+
1172
+ initializeShadeRoot({
1173
+ injector,
1174
+ rootElement,
1175
+ jsxElement: (
1176
+ <div>
1177
+ <NestedRouteLink id="go-about" href="/about">
1178
+ about
1179
+ </NestedRouteLink>
1180
+ <NestedRouter
1181
+ viewTransition={{ types: ['slide'] }}
1182
+ routes={{
1183
+ '/about': { component: () => <div id="content">about</div> },
1184
+ '/': { component: () => <div id="content">home</div> },
1185
+ }}
1186
+ />
1187
+ </div>
1188
+ ),
1189
+ })
1190
+
1191
+ await flushUpdates()
1192
+ startViewTransitionSpy.mockClear()
1193
+
1194
+ document.getElementById('go-about')?.click()
1195
+ await flushUpdates()
1196
+
1197
+ expect(startViewTransitionSpy).toHaveBeenCalledWith(expect.objectContaining({ types: ['slide'] }))
1198
+ })
1199
+ })
1200
+
1201
+ it('should respect per-route viewTransition: false override', async () => {
1202
+ history.pushState(null, '', '/')
1203
+
1204
+ await usingAsync(new Injector(), async (injector) => {
1205
+ const rootElement = document.getElementById('root') as HTMLDivElement
1206
+
1207
+ initializeShadeRoot({
1208
+ injector,
1209
+ rootElement,
1210
+ jsxElement: (
1211
+ <div>
1212
+ <NestedRouteLink id="go-about" href="/about">
1213
+ about
1214
+ </NestedRouteLink>
1215
+ <NestedRouter
1216
+ viewTransition
1217
+ routes={{
1218
+ '/about': {
1219
+ component: () => <div id="content">about</div>,
1220
+ viewTransition: false,
1221
+ },
1222
+ '/': { component: () => <div id="content">home</div> },
1223
+ }}
1224
+ />
1225
+ </div>
1226
+ ),
1227
+ })
1228
+
1229
+ await flushUpdates()
1230
+ startViewTransitionSpy.mockClear()
1231
+
1232
+ document.getElementById('go-about')?.click()
1233
+ await flushUpdates()
1234
+
1235
+ expect(startViewTransitionSpy).not.toHaveBeenCalled()
1236
+ expect(document.getElementById('content')?.innerHTML).toBe('about')
1237
+ })
1238
+ })
1239
+
1240
+ it('should fall back gracefully when startViewTransition is not available', async () => {
1241
+ delete (document as unknown as Record<string, unknown>).startViewTransition
1242
+
1243
+ history.pushState(null, '', '/')
1244
+
1245
+ await usingAsync(new Injector(), async (injector) => {
1246
+ const rootElement = document.getElementById('root') as HTMLDivElement
1247
+
1248
+ initializeShadeRoot({
1249
+ injector,
1250
+ rootElement,
1251
+ jsxElement: (
1252
+ <div>
1253
+ <NestedRouteLink id="go-about" href="/about">
1254
+ about
1255
+ </NestedRouteLink>
1256
+ <NestedRouter
1257
+ viewTransition
1258
+ routes={{
1259
+ '/about': { component: () => <div id="content">about</div> },
1260
+ '/': { component: () => <div id="content">home</div> },
1261
+ }}
1262
+ />
1263
+ </div>
1264
+ ),
1265
+ })
1266
+
1267
+ await flushUpdates()
1268
+
1269
+ document.getElementById('go-about')?.click()
1270
+ await flushUpdates()
1271
+
1272
+ expect(document.getElementById('content')?.innerHTML).toBe('about')
1273
+ })
1274
+ })
1275
+ })
@@ -7,6 +7,8 @@ import { LocationService } from '../services/location-service.js'
7
7
  import { RouteMatchService } from '../services/route-match-service.js'
8
8
  import { createComponent, setRenderMode } from '../shade-component.js'
9
9
  import { Shade } from '../shade.js'
10
+ import type { ViewTransitionConfig } from '../view-transition.js'
11
+ import { maybeViewTransition } from '../view-transition.js'
10
12
 
11
13
  /**
12
14
  * Options passed to a dynamic title resolver function.
@@ -55,9 +57,21 @@ export type NestedRoute<TMatchResult = unknown> = {
55
57
  outlet?: JSX.Element
56
58
  }) => JSX.Element
57
59
  routingOptions?: MatchOptions
60
+ /**
61
+ * Called after the route's DOM has been mounted. When view transitions are enabled,
62
+ * this runs after the transition's update callback has completed and the new DOM is in place.
63
+ * Use for imperative side effects like data fetching or focus management — not for visual
64
+ * animations, which are handled by the View Transition API when `viewTransition` is enabled.
65
+ */
58
66
  onVisit?: (options: RenderOptions<unknown> & { element: JSX.Element }) => Promise<void>
67
+ /**
68
+ * Called before the route's DOM is removed (and before the view transition starts, if enabled).
69
+ * Use for cleanup or teardown logic — not for exit animations, which are handled by the
70
+ * View Transition API when `viewTransition` is enabled.
71
+ */
59
72
  onLeave?: (options: RenderOptions<unknown> & { element: JSX.Element }) => Promise<void>
60
73
  children?: Record<string, NestedRoute<any>>
74
+ viewTransition?: boolean | ViewTransitionConfig
61
75
  }
62
76
 
63
77
  /**
@@ -67,6 +81,7 @@ export type NestedRoute<TMatchResult = unknown> = {
67
81
  export type NestedRouterProps = {
68
82
  routes: Record<string, NestedRoute<any>>
69
83
  notFound?: JSX.Element
84
+ viewTransition?: boolean | ViewTransitionConfig
70
85
  }
71
86
 
72
87
  /**
@@ -200,6 +215,29 @@ export const renderMatchChain = (chain: MatchChainEntry[], currentUrl: string):
200
215
  return { jsx: outlet as JSX.Element, chainElements }
201
216
  }
202
217
 
218
+ /**
219
+ * Resolves the effective view transition config for a navigation by merging
220
+ * the router-level default with the innermost (leaf) route's override.
221
+ * A per-route `false` disables transitions even when the router default is on.
222
+ */
223
+ export const resolveViewTransition = (
224
+ routerConfig: boolean | ViewTransitionConfig | undefined,
225
+ newChain: MatchChainEntry[],
226
+ ): ViewTransitionConfig | false => {
227
+ if (!routerConfig && routerConfig !== undefined) return false
228
+
229
+ const leafRoute = newChain[newChain.length - 1]?.route
230
+ const routeConfig = leafRoute?.viewTransition
231
+
232
+ if (routeConfig === false) return false
233
+ if (!routerConfig && !routeConfig) return false
234
+
235
+ const baseTypes = typeof routerConfig === 'object' ? routerConfig.types : undefined
236
+ const routeTypes = typeof routeConfig === 'object' ? routeConfig.types : undefined
237
+
238
+ return { types: routeTypes ?? baseTypes }
239
+ }
240
+
203
241
  /**
204
242
  * A nested router component that supports hierarchical route definitions
205
243
  * with parent/child relationships. Parent routes receive an `outlet` prop
@@ -237,7 +275,6 @@ export const NestedRouter = Shade<NestedRouterProps>({
237
275
  if (hasChanged) {
238
276
  const version = ++versionRef.current
239
277
 
240
- // Call onLeave for routes that are being left (from divergence point to end of old chain)
241
278
  for (let i = lastChainEntries.length - 1; i >= divergeIndex; i--) {
242
279
  await lastChainEntries[i].route.onLeave?.({ ...options, element: lastChainElements[i] })
243
280
  if (version !== versionRef.current) return
@@ -251,10 +288,15 @@ export const NestedRouter = Shade<NestedRouterProps>({
251
288
  setRenderMode(false)
252
289
  }
253
290
  if (version !== versionRef.current) return
254
- setState({ matchChain: newChain, jsx: newResult.jsx, chainElements: newResult.chainElements })
255
- injector.getInstance(RouteMatchService).currentMatchChain.setValue(newChain)
256
291
 
257
- // Call onVisit for routes that are being entered (from divergence point to end of new chain)
292
+ const applyUpdate = () => {
293
+ setState({ matchChain: newChain, jsx: newResult.jsx, chainElements: newResult.chainElements })
294
+ injector.getInstance(RouteMatchService).currentMatchChain.setValue(newChain)
295
+ }
296
+
297
+ const vtConfig = resolveViewTransition(options.props.viewTransition, newChain)
298
+ await maybeViewTransition(vtConfig === false ? undefined : vtConfig, applyUpdate)
299
+
258
300
  for (let i = divergeIndex; i < newChain.length; i++) {
259
301
  await newChain[i].route.onVisit?.({ ...options, element: newResult.chainElements[i] })
260
302
  if (version !== versionRef.current) return
@@ -263,18 +305,21 @@ export const NestedRouter = Shade<NestedRouterProps>({
263
305
  } else if (lastChain !== null) {
264
306
  const version = ++versionRef.current
265
307
 
266
- // No match found — call onLeave for all active routes and show notFound.
267
- // The null sentinel prevents re-entering this block on re-render.
268
308
  for (let i = (lastChain?.length ?? 0) - 1; i >= 0; i--) {
269
309
  await lastChain[i].route.onLeave?.({ ...options, element: lastChainElements[i] })
270
310
  if (version !== versionRef.current) return
271
311
  }
272
- setState({
273
- matchChain: null,
274
- jsx: options.props.notFound || <div />,
275
- chainElements: [],
276
- })
277
- injector.getInstance(RouteMatchService).currentMatchChain.setValue([])
312
+
313
+ const applyNotFound = () => {
314
+ setState({
315
+ matchChain: null,
316
+ jsx: options.props.notFound || <div />,
317
+ chainElements: [],
318
+ })
319
+ injector.getInstance(RouteMatchService).currentMatchChain.setValue([])
320
+ }
321
+
322
+ await maybeViewTransition(options.props.viewTransition, applyNotFound)
278
323
  }
279
324
  } catch (e) {
280
325
  if (!(e instanceof ObservableAlreadyDisposedError)) {
package/src/index.ts CHANGED
@@ -9,4 +9,5 @@ export * from './shade.js'
9
9
  export * from './style-manager.js'
10
10
  export * from './styled-element.js'
11
11
  export * from './styled-shade.js'
12
+ export * from './view-transition.js'
12
13
  import './jsx.js'
@@ -0,0 +1,218 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest'
2
+ import type { ViewTransitionConfig } from './view-transition.js'
3
+ import { maybeViewTransition, transitionedValue } from './view-transition.js'
4
+
5
+ describe('maybeViewTransition', () => {
6
+ afterEach(() => {
7
+ delete (document as unknown as Record<string, unknown>).startViewTransition
8
+ })
9
+
10
+ const mockStartViewTransition = () => {
11
+ const spy = vi.fn((optionsOrCallback: StartViewTransitionOptions | (() => void)) => {
12
+ const update = typeof optionsOrCallback === 'function' ? optionsOrCallback : optionsOrCallback.update
13
+ update?.()
14
+ return {
15
+ finished: Promise.resolve(),
16
+ ready: Promise.resolve(),
17
+ updateCallbackDone: Promise.resolve(),
18
+ skipTransition: vi.fn(),
19
+ } as unknown as ViewTransition
20
+ })
21
+ document.startViewTransition = spy as typeof document.startViewTransition
22
+ return spy
23
+ }
24
+
25
+ it('should call update directly when config is undefined', () => {
26
+ const spy = mockStartViewTransition()
27
+ const update = vi.fn()
28
+ const result = maybeViewTransition(undefined, update)
29
+ expect(update).toHaveBeenCalledTimes(1)
30
+ expect(spy).not.toHaveBeenCalled()
31
+ expect(result).toBeUndefined()
32
+ })
33
+
34
+ it('should call update directly when config is false', () => {
35
+ const spy = mockStartViewTransition()
36
+ const update = vi.fn()
37
+ const result = maybeViewTransition(false, update)
38
+ expect(update).toHaveBeenCalledTimes(1)
39
+ expect(spy).not.toHaveBeenCalled()
40
+ expect(result).toBeUndefined()
41
+ })
42
+
43
+ it('should call update directly when startViewTransition is not available', () => {
44
+ const update = vi.fn()
45
+ const result = maybeViewTransition(true, update)
46
+ expect(update).toHaveBeenCalledTimes(1)
47
+ expect(result).toBeUndefined()
48
+ })
49
+
50
+ it('should call startViewTransition when config is true and API is available', () => {
51
+ const spy = mockStartViewTransition()
52
+ const update = vi.fn()
53
+ const result = maybeViewTransition(true, update)
54
+ expect(spy).toHaveBeenCalledTimes(1)
55
+ expect(update).toHaveBeenCalledTimes(1)
56
+ expect(result).toBeInstanceOf(Promise)
57
+ })
58
+
59
+ it('should use callback form when config is true', () => {
60
+ const spy = mockStartViewTransition()
61
+ const update = vi.fn()
62
+ void maybeViewTransition(true, update)
63
+ expect(spy).toHaveBeenCalledWith(update)
64
+ })
65
+
66
+ it('should pass types when config is an object with types', () => {
67
+ const spy = mockStartViewTransition()
68
+ const update = vi.fn()
69
+ const config: ViewTransitionConfig = { types: ['slide', 'fade'] }
70
+ void maybeViewTransition(config, update)
71
+ expect(spy).toHaveBeenCalledWith({ update, types: ['slide', 'fade'] })
72
+ })
73
+
74
+ it('should use callback form when config object has empty types array', () => {
75
+ const spy = mockStartViewTransition()
76
+ const update = vi.fn()
77
+ const config: ViewTransitionConfig = { types: [] }
78
+ void maybeViewTransition(config, update)
79
+ expect(spy).toHaveBeenCalledWith(update)
80
+ })
81
+
82
+ it('should use callback form when config object has no types', () => {
83
+ const spy = mockStartViewTransition()
84
+ const update = vi.fn()
85
+ const config: ViewTransitionConfig = {}
86
+ void maybeViewTransition(config, update)
87
+ expect(spy).toHaveBeenCalledWith(update)
88
+ })
89
+
90
+ it('should return updateCallbackDone promise when transition is started', async () => {
91
+ mockStartViewTransition()
92
+ const update = vi.fn()
93
+ const result = maybeViewTransition(true, update)
94
+ expect(result).toBeInstanceOf(Promise)
95
+ await expect(result).resolves.toBeUndefined()
96
+ })
97
+ })
98
+
99
+ describe('transitionedValue', () => {
100
+ afterEach(() => {
101
+ delete (document as unknown as Record<string, unknown>).startViewTransition
102
+ })
103
+
104
+ const mockStartViewTransition = () => {
105
+ const spy = vi.fn((optionsOrCallback: StartViewTransitionOptions | (() => void)) => {
106
+ const update = typeof optionsOrCallback === 'function' ? optionsOrCallback : optionsOrCallback.update
107
+ update?.()
108
+ return {
109
+ finished: Promise.resolve(),
110
+ ready: Promise.resolve(),
111
+ updateCallbackDone: Promise.resolve(),
112
+ skipTransition: vi.fn(),
113
+ } as unknown as ViewTransition
114
+ })
115
+ document.startViewTransition = spy as typeof document.startViewTransition
116
+ return spy
117
+ }
118
+
119
+ const createMockUseState = () => {
120
+ const store = new Map<string, unknown>()
121
+ const setters = new Map<string, (v: unknown) => void>()
122
+ const mockUseState = <S>(key: string, initialValue: S): [S, (v: S) => void] => {
123
+ if (!store.has(key)) {
124
+ store.set(key, initialValue)
125
+ }
126
+ const setValue = (v: S) => {
127
+ store.set(key, v)
128
+ }
129
+ setters.set(key, setValue as (v: unknown) => void)
130
+ return [store.get(key) as S, setValue]
131
+ }
132
+ return { mockUseState, store }
133
+ }
134
+
135
+ it('should return the value when it equals the displayed value', () => {
136
+ const { mockUseState } = createMockUseState()
137
+ const result = transitionedValue(mockUseState, 'key', 'hello', true)
138
+ expect(result).toBe('hello')
139
+ })
140
+
141
+ it('should not call startViewTransition when value has not changed', () => {
142
+ const spy = mockStartViewTransition()
143
+ const { mockUseState } = createMockUseState()
144
+ transitionedValue(mockUseState, 'key', 'hello', true)
145
+ expect(spy).not.toHaveBeenCalled()
146
+ })
147
+
148
+ it('should call startViewTransition when value changes and config is truthy', () => {
149
+ const spy = mockStartViewTransition()
150
+ const { mockUseState, store } = createMockUseState()
151
+
152
+ transitionedValue(mockUseState, 'key', 'initial', true)
153
+ store.set('key', 'initial')
154
+
155
+ transitionedValue(mockUseState, 'key', 'updated', true)
156
+ expect(spy).toHaveBeenCalledTimes(1)
157
+ expect(store.get('key')).toBe('updated')
158
+ })
159
+
160
+ it('should not call startViewTransition when config is falsy', () => {
161
+ const spy = mockStartViewTransition()
162
+ const { mockUseState, store } = createMockUseState()
163
+
164
+ transitionedValue(mockUseState, 'key', 'initial', undefined)
165
+ store.set('key', 'initial')
166
+
167
+ transitionedValue(mockUseState, 'key', 'updated', undefined)
168
+ expect(spy).not.toHaveBeenCalled()
169
+ expect(store.get('key')).toBe('updated')
170
+ })
171
+
172
+ it('should not call startViewTransition when shouldTransition returns false', () => {
173
+ const spy = mockStartViewTransition()
174
+ const { mockUseState, store } = createMockUseState()
175
+
176
+ transitionedValue(mockUseState, 'key', 'initial', true, () => false)
177
+ store.set('key', 'initial')
178
+
179
+ transitionedValue(mockUseState, 'key', 'updated', true, () => false)
180
+ expect(spy).not.toHaveBeenCalled()
181
+ expect(store.get('key')).toBe('updated')
182
+ })
183
+
184
+ it('should call startViewTransition when shouldTransition returns true', () => {
185
+ const spy = mockStartViewTransition()
186
+ const { mockUseState, store } = createMockUseState()
187
+
188
+ transitionedValue(mockUseState, 'key', 'initial', true, () => true)
189
+ store.set('key', 'initial')
190
+
191
+ transitionedValue(mockUseState, 'key', 'updated', true, () => true)
192
+ expect(spy).toHaveBeenCalledTimes(1)
193
+ expect(store.get('key')).toBe('updated')
194
+ })
195
+
196
+ it('should pass prev and next values to shouldTransition', () => {
197
+ mockStartViewTransition()
198
+ const { mockUseState, store } = createMockUseState()
199
+ const shouldTransition = vi.fn(() => true)
200
+
201
+ transitionedValue(mockUseState, 'key', 'initial', true, shouldTransition)
202
+ store.set('key', 'initial')
203
+
204
+ transitionedValue(mockUseState, 'key', 'updated', true, shouldTransition)
205
+ expect(shouldTransition).toHaveBeenCalledWith('initial', 'updated')
206
+ })
207
+
208
+ it('should default shouldTransition to always true', () => {
209
+ const spy = mockStartViewTransition()
210
+ const { mockUseState, store } = createMockUseState()
211
+
212
+ transitionedValue(mockUseState, 'key', 'a', true)
213
+ store.set('key', 'a')
214
+
215
+ transitionedValue(mockUseState, 'key', 'b', true)
216
+ expect(spy).toHaveBeenCalledTimes(1)
217
+ })
218
+ })