@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.
- package/CHANGELOG.md +68 -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-route-link.d.ts.map +1 -1
- package/esm/components/nested-route-link.js +1 -0
- package/esm/components/nested-route-link.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/components/route-link.d.ts.map +1 -1
- package/esm/components/route-link.js +1 -0
- package/esm/components/route-link.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/services/location-service.d.ts +13 -0
- package/esm/services/location-service.d.ts.map +1 -1
- package/esm/services/location-service.js +21 -1
- package/esm/services/location-service.js.map +1 -1
- package/esm/services/screen-service.d.ts.map +1 -1
- package/esm/services/screen-service.js +4 -0
- package/esm/services/screen-service.js.map +1 -1
- package/esm/shade-resources.integration.spec.js.map +1 -1
- package/esm/shade.spec.js +1 -0
- package/esm/shade.spec.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/esm/vnode.integration.spec.js +3 -1
- package/esm/vnode.integration.spec.js.map +1 -1
- package/package.json +4 -4
- package/src/components/lazy-load.spec.tsx +78 -1
- package/src/components/lazy-load.tsx +10 -3
- package/src/components/nested-route-link.tsx +1 -0
- package/src/components/nested-router.spec.tsx +249 -0
- package/src/components/nested-router.tsx +57 -12
- package/src/components/route-link.tsx +1 -0
- package/src/index.ts +1 -0
- package/src/services/location-service.tsx +22 -1
- package/src/services/screen-service.ts +4 -0
- package/src/shade-resources.integration.spec.tsx +1 -0
- package/src/shade.spec.tsx +1 -0
- package/src/view-transition.spec.ts +218 -0
- package/src/view-transition.ts +66 -0
- 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
|
-
|
|
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)) {
|
|
@@ -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
|
@@ -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
|
)
|
package/src/shade.spec.tsx
CHANGED
|
@@ -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
|
})
|