@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.
- package/CHANGELOG.md +53 -0
- package/esm/components/lazy-load.d.ts +2 -0
- package/esm/components/lazy-load.d.ts.map +1 -1
- package/esm/components/lazy-load.js +8 -3
- package/esm/components/lazy-load.js.map +1 -1
- package/esm/components/lazy-load.spec.js +59 -1
- package/esm/components/lazy-load.spec.js.map +1 -1
- package/esm/components/nested-router.d.ts +20 -0
- package/esm/components/nested-router.d.ts.map +1 -1
- package/esm/components/nested-router.js +34 -12
- package/esm/components/nested-router.js.map +1 -1
- package/esm/components/nested-router.spec.js +167 -1
- package/esm/components/nested-router.spec.js.map +1 -1
- package/esm/index.d.ts +1 -0
- package/esm/index.d.ts.map +1 -1
- package/esm/index.js +1 -0
- package/esm/index.js.map +1 -1
- package/esm/view-transition.d.ts +38 -0
- package/esm/view-transition.d.ts.map +1 -0
- package/esm/view-transition.js +50 -0
- package/esm/view-transition.js.map +1 -0
- package/esm/view-transition.spec.d.ts +2 -0
- package/esm/view-transition.spec.d.ts.map +1 -0
- package/esm/view-transition.spec.js +184 -0
- package/esm/view-transition.spec.js.map +1 -0
- package/package.json +1 -1
- package/src/components/lazy-load.spec.tsx +78 -1
- package/src/components/lazy-load.tsx +10 -3
- package/src/components/nested-router.spec.tsx +249 -0
- package/src/components/nested-router.tsx +57 -12
- package/src/index.ts +1 -0
- package/src/view-transition.spec.ts +218 -0
- package/src/view-transition.ts +66 -0
|
@@ -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
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
@@ -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
|
+
})
|