@furystack/shades 12.3.0 → 12.5.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.
Files changed (58) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/esm/components/lazy-load.d.ts +2 -0
  3. package/esm/components/lazy-load.d.ts.map +1 -1
  4. package/esm/components/lazy-load.js +8 -3
  5. package/esm/components/lazy-load.js.map +1 -1
  6. package/esm/components/lazy-load.spec.js +59 -1
  7. package/esm/components/lazy-load.spec.js.map +1 -1
  8. package/esm/components/nested-route-link.d.ts.map +1 -1
  9. package/esm/components/nested-route-link.js +1 -0
  10. package/esm/components/nested-route-link.js.map +1 -1
  11. package/esm/components/nested-router.d.ts +20 -0
  12. package/esm/components/nested-router.d.ts.map +1 -1
  13. package/esm/components/nested-router.js +34 -12
  14. package/esm/components/nested-router.js.map +1 -1
  15. package/esm/components/nested-router.spec.js +167 -1
  16. package/esm/components/nested-router.spec.js.map +1 -1
  17. package/esm/components/route-link.d.ts.map +1 -1
  18. package/esm/components/route-link.js +1 -0
  19. package/esm/components/route-link.js.map +1 -1
  20. package/esm/index.d.ts +1 -0
  21. package/esm/index.d.ts.map +1 -1
  22. package/esm/index.js +1 -0
  23. package/esm/index.js.map +1 -1
  24. package/esm/services/location-service.d.ts +13 -0
  25. package/esm/services/location-service.d.ts.map +1 -1
  26. package/esm/services/location-service.js +21 -1
  27. package/esm/services/location-service.js.map +1 -1
  28. package/esm/services/screen-service.d.ts.map +1 -1
  29. package/esm/services/screen-service.js +4 -0
  30. package/esm/services/screen-service.js.map +1 -1
  31. package/esm/shade-resources.integration.spec.js.map +1 -1
  32. package/esm/shade.spec.js +1 -0
  33. package/esm/shade.spec.js.map +1 -1
  34. package/esm/view-transition.d.ts +38 -0
  35. package/esm/view-transition.d.ts.map +1 -0
  36. package/esm/view-transition.js +50 -0
  37. package/esm/view-transition.js.map +1 -0
  38. package/esm/view-transition.spec.d.ts +2 -0
  39. package/esm/view-transition.spec.d.ts.map +1 -0
  40. package/esm/view-transition.spec.js +184 -0
  41. package/esm/view-transition.spec.js.map +1 -0
  42. package/esm/vnode.integration.spec.js +3 -1
  43. package/esm/vnode.integration.spec.js.map +1 -1
  44. package/package.json +4 -4
  45. package/src/components/lazy-load.spec.tsx +78 -1
  46. package/src/components/lazy-load.tsx +10 -3
  47. package/src/components/nested-route-link.tsx +1 -0
  48. package/src/components/nested-router.spec.tsx +249 -0
  49. package/src/components/nested-router.tsx +57 -12
  50. package/src/components/route-link.tsx +1 -0
  51. package/src/index.ts +1 -0
  52. package/src/services/location-service.tsx +22 -1
  53. package/src/services/screen-service.ts +4 -0
  54. package/src/shade-resources.integration.spec.tsx +1 -0
  55. package/src/shade.spec.tsx +1 -0
  56. package/src/view-transition.spec.ts +218 -0
  57. package/src/view-transition.ts +66 -0
  58. package/src/vnode.integration.spec.tsx +3 -1
@@ -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)) {
@@ -19,6 +19,7 @@ export const RouteLink = Shade<RouteLinkProps>({
19
19
  useHostProps({
20
20
  onclick: (ev: MouseEvent) => {
21
21
  ev.preventDefault()
22
+ // eslint-disable-next-line furystack/prefer-location-service -- Deprecated framework component; must call pushState directly.
22
23
  history.pushState('', props.title || '', props.href)
23
24
  injector.getInstance(LocationService).updateState()
24
25
  },
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'
@@ -39,10 +39,11 @@ export class LocationService implements Disposable {
39
39
  public [Symbol.dispose]() {
40
40
  window.removeEventListener('popstate', this.popStateListener)
41
41
  window.removeEventListener('hashchange', this.hashChangeListener)
42
- this.onLocationPathChanged[Symbol.dispose]()
43
42
  this.onLocationSearchChanged[Symbol.dispose]()
44
43
  this.onDeserializedLocationSearchChanged[Symbol.dispose]()
45
44
  this.locationDeserializerObserver[Symbol.dispose]()
45
+ this.onLocationPathChanged[Symbol.dispose]()
46
+ this.onLocationHashChanged[Symbol.dispose]()
46
47
 
47
48
  window.history.pushState = this.originalPushState
48
49
  window.history.replaceState = this.originalReplaceState
@@ -69,6 +70,10 @@ export class LocationService implements Disposable {
69
70
  this.onDeserializedLocationSearchChanged.setValue(this.deserializeQueryString(search))
70
71
  })
71
72
 
73
+ /**
74
+ * Synchronizes the observable state with the current browser location.
75
+ * Called internally after navigation events and history state changes.
76
+ */
72
77
  public updateState = (() => {
73
78
  this.onLocationPathChanged.setValue(location.pathname)
74
79
  this.onLocationHashChanged.setValue(location.hash.replace('#', ''))
@@ -80,10 +85,25 @@ export class LocationService implements Disposable {
80
85
  * The LocationService interceptor ensures routing state is updated correctly.
81
86
  */
82
87
  public navigate(path: string): void {
88
+ // eslint-disable-next-line furystack/prefer-location-service -- This IS the LocationService.navigate() implementation.
83
89
  history.pushState(null, '', path)
84
90
  this.updateState()
85
91
  }
86
92
 
93
+ /**
94
+ * Replace the current history entry with a new path. Use this instead of
95
+ * raw history.replaceState for SPA redirects (e.g. the intermediate URL
96
+ * should not appear in the browser's back/forward stack).
97
+ */
98
+ public replace(path: string): void {
99
+ // eslint-disable-next-line furystack/prefer-location-service -- This IS the LocationService.replace() implementation.
100
+ history.replaceState(null, '', path)
101
+ this.updateState()
102
+ }
103
+
104
+ /**
105
+ * Internal cache of per-key search parameter observables created by {@link useSearchParam}.
106
+ */
87
107
  public readonly searchParamObservables = new Map<string, ObservableValue<any>>()
88
108
 
89
109
  /**
@@ -110,6 +130,7 @@ export class LocationService implements Disposable {
110
130
  if (currentQueryStringObject[key] !== value) {
111
131
  const params = this.serializeToQueryString({ ...currentQueryStringObject, [key]: value })
112
132
  const newUrl = `${location.pathname}?${params}`
133
+ // eslint-disable-next-line furystack/prefer-location-service -- Internal LocationService plumbing for search param sync.
113
134
  history.pushState({}, '', newUrl)
114
135
  }
115
136
  })
@@ -83,6 +83,10 @@ export class ScreenService implements Disposable {
83
83
  */
84
84
  public [Symbol.dispose]() {
85
85
  window.removeEventListener('resize', this.onResizeListener)
86
+ this.orientation[Symbol.dispose]()
87
+ Object.values(this.screenSize.atLeast).forEach((observable) => {
88
+ observable[Symbol.dispose]()
89
+ })
86
90
  }
87
91
 
88
92
  /**
@@ -162,6 +162,7 @@ describe('Shade Resources integration tests', () => {
162
162
  renderCounter()
163
163
  return (
164
164
  <div ref={valRef} id="manual-val">
165
+ {/* eslint-disable-next-line furystack/no-direct-get-value-in-render -- Test: verifying manual DOM update pattern with onChange callback */}
165
166
  {obs.getValue()}
166
167
  </div>
167
168
  )
@@ -183,6 +183,7 @@ describe('Shade edge cases', () => {
183
183
  },
184
184
  }))
185
185
  renderCounter()
186
+ // eslint-disable-next-line furystack/no-direct-get-value-in-render -- Test: verifying no re-render during disposal; already subscribed via useObservable above
186
187
  return <div>{obs.getValue()}</div>
187
188
  },
188
189
  })