handy online tools for AT Protocol boat.kelinci.net
atproto bluesky atcute typescript solidjs

chore: update router implementation

mary.my.id c74c6bbe 016273e4

verified
Changed files
+79 -91
src
lib
navigation
+1 -1
package.json
··· 20 20 "@atcute/tid": "^1.0.2", 21 21 "@badrap/valita": "^0.4.3", 22 22 "@mary/array-fns": "npm:@jsr/mary__array-fns@^0.1.4", 23 - "@mary/events": "npm:@jsr/mary__events@^0.1.0", 23 + "@mary/events": "npm:@jsr/mary__events@^0.2.0", 24 24 "@mary/solid-freeze": "npm:@externdefs/solid-freeze@^0.1.1", 25 25 "@mary/tar": "npm:@jsr/mary__tar@^0.2.4", 26 26 "nanoid": "^5.1.5",
+5 -5
pnpm-lock.yaml
··· 48 48 specifier: npm:@jsr/mary__array-fns@^0.1.4 49 49 version: '@jsr/mary__array-fns@0.1.4' 50 50 '@mary/events': 51 - specifier: npm:@jsr/mary__events@^0.1.0 52 - version: '@jsr/mary__events@0.1.0' 51 + specifier: npm:@jsr/mary__events@^0.2.0 52 + version: '@jsr/mary__events@0.2.0' 53 53 '@mary/solid-freeze': 54 54 specifier: npm:@externdefs/solid-freeze@^0.1.1 55 55 version: '@externdefs/solid-freeze@0.1.1(solid-js@1.9.5)' ··· 732 732 '@jsr/mary__array-fns@0.1.4': 733 733 resolution: {integrity: sha512-+HbGYR9Ll5blEmAvVAoPejyGj01YeBbVmJ59qxaMDKt5i3F90ohYLA5a78y6AULDlet1IxYB+a/cMN+A0vGnDg==, tarball: https://npm.jsr.io/~/11/@jsr/mary__array-fns/0.1.4.tgz} 734 734 735 - '@jsr/mary__events@0.1.0': 736 - resolution: {integrity: sha512-oS6jVOaXTaNEa6avRncwrEtUYaBKrq/HEybPa9Z3aoeMs+RSly0vn0KcOj/fy2H6iTBkeh3wa8+/9nFjhKyKIg==, tarball: https://npm.jsr.io/~/11/@jsr/mary__events/0.1.0.tgz} 735 + '@jsr/mary__events@0.2.0': 736 + resolution: {integrity: sha512-WcBRbtuTno3zcfXKd7SEeKr1lAJF+CQ8BCv+PEEMmNKNqFurkEksGxRB3UDPZxIxjJ7sAqMVTL26wRuMpAcIeA==, tarball: https://npm.jsr.io/~/11/@jsr/mary__events/0.2.0.tgz} 737 737 738 738 '@jsr/mary__tar@0.2.4': 739 739 resolution: {integrity: sha512-jFjPcZj8DRSukPLZOt6+h74cVFdfdTMG9gzbW67YByCJTD52PEpe2sNcfCSw4mQ8hZBNgwiufCPyYL8hR9yicA==, tarball: https://npm.jsr.io/~/11/@jsr/mary__tar/0.2.4.tgz} ··· 2208 2208 2209 2209 '@jsr/mary__array-fns@0.1.4': {} 2210 2210 2211 - '@jsr/mary__events@0.1.0': {} 2211 + '@jsr/mary__events@0.2.0': {} 2212 2212 2213 2213 '@jsr/mary__tar@0.2.4': {} 2214 2214
+17 -44
src/lib/navigation/history.ts
··· 3 3 // Commit: 3e9dab413f4eda8d6bce565388c5ddb7aeff9f7e 4 4 // Most of the changes are just trimming it down to only include the browser 5 5 // history implementation. 6 + import { nanoid } from 'nanoid/non-secure'; 7 + 8 + import { EventEmitter } from '@mary/events'; 6 9 7 10 export type Action = 'traverse' | 'push' | 'replace' | 'update'; 8 11 ··· 111 114 let blockedPopTx: Transition | null = null; 112 115 const handlePop = () => { 113 116 if (blockedPopTx) { 114 - blockers.call(blockedPopTx); 117 + emitter.emit('block', blockedPopTx); 115 118 blockedPopTx = null; 116 119 } else { 117 120 const nextAction: Action = 'traverse'; 118 121 const nextLocation = getCurrentLocation(); 119 122 const nextIndex = nextLocation.index; 120 123 121 - if (blockers.length) { 124 + if (emitter.has('block')) { 122 125 if (nextIndex != null) { 123 126 const delta = location.index - nextIndex; 124 127 if (delta) { ··· 154 157 } 155 158 }; 156 159 157 - const listeners = createEvents<Listener>(); 158 - const blockers = createEvents<Blocker>(); 160 + const emitter = new EventEmitter<{ 161 + update: [evt: Update]; 162 + block: [tx: Transition]; 163 + }>(); 159 164 160 165 let location = getCurrentLocation(); 161 166 ··· 194 199 }; 195 200 196 201 const allowTx = (action: Action, location: Location, retry: () => void): boolean => { 197 - return !blockers.length || (blockers.call({ action, location, retry }), false); 202 + return !emitter.emit('block', { action, location, retry }); 198 203 }; 199 204 200 205 const applyTx = (nextAction: Action): void => { 201 206 location = getCurrentLocation(); 202 - listeners.call({ action: nextAction, location }); 207 + emitter.emit('update', { action: nextAction, location }); 203 208 }; 204 209 205 210 const navigate = (to: To, { replace, state }: NavigateOptions = {}): void => { ··· 263 268 return go(1); 264 269 }, 265 270 listen: (listener) => { 266 - return listeners.push(listener); 271 + return emitter.on('update', listener); 267 272 }, 268 273 block: (blocker) => { 269 - const unblock = blockers.push(blocker); 270 - 271 - if (blockers.length === 1) { 274 + if (!emitter.has('block')) { 272 275 window.addEventListener(BeforeUnloadEventType, promptBeforeUnload); 273 276 } 274 277 278 + const unblock = emitter.on('block', blocker); 279 + 275 280 return () => { 276 281 unblock(); 277 282 278 283 // Remove the beforeunload listener so the document may 279 284 // still be salvageable in the pagehide event. 280 285 // See https://html.spec.whatwg.org/#unloading-documents 281 - if (!blockers.length) { 286 + if (!emitter.has('block')) { 282 287 window.removeEventListener(BeforeUnloadEventType, promptBeforeUnload); 283 288 } 284 289 }; ··· 293 298 event.preventDefault(); 294 299 }; 295 300 296 - interface Events<F extends (arg: any) => void> { 297 - length: number; 298 - push: (fn: F) => () => void; 299 - call: (arg: Parameters<F>[0]) => void; 300 - } 301 - 302 - const createEvents = <F extends (arg: any) => void>(): Events<F> => { 303 - const handlers: F[] = []; 304 - 305 - return { 306 - get length() { 307 - return handlers.length; 308 - }, 309 - push(fn: F) { 310 - handlers.push(fn); 311 - 312 - return () => { 313 - const index = handlers.indexOf(fn); 314 - 315 - if (index !== -1) { 316 - handlers.splice(index, 1); 317 - } 318 - }; 319 - }, 320 - call(arg) { 321 - for (let idx = 0, len = handlers.length; idx < len; idx++) { 322 - (0, handlers[idx])(arg); 323 - } 324 - }, 325 - }; 326 - }; 327 - 328 301 const createKey = () => { 329 - return crypto.randomUUID(); 302 + return nanoid(); 330 303 }; 331 304 332 305 /**
+56 -41
src/lib/navigation/router.tsx
··· 1 1 /* @refresh reload */ 2 2 import { 3 + type Accessor, 3 4 type Component, 4 5 For, 5 6 type JSX, ··· 11 12 createSignal, 12 13 getOwner, 13 14 onCleanup, 15 + untrack, 14 16 useContext, 15 17 } from 'solid-js'; 16 18 import { delegateEvents } from 'solid-js/web'; ··· 57 59 58 60 export interface MatchedRouteState extends MatchedRoute { 59 61 readonly id: string; 62 + scrollPos: { x: number; y: number } | undefined; 60 63 } 61 64 62 65 interface RouterState { ··· 68 71 interface ViewContextObject { 69 72 owner: Owner | null; 70 73 route: MatchedRouteState; 74 + isActive: () => boolean; 71 75 } 72 76 73 77 let _entry: Location; ··· 86 90 enter: boolean; 87 91 } 88 92 89 - const routerEvents = new EventEmitter<{ [key: string]: (event: RouteEvent) => void }>(); 93 + const routerEvents = new EventEmitter<{ [key: string]: [event: RouteEvent] }>(); 90 94 91 95 export { routerEvents as UNSAFE_routerEvents }; 92 96 ··· 105 109 const nextKey = matched.id || _entry.key; 106 110 107 111 const isSingle = !!matched.id; 108 - const matchedState: MatchedRouteState = { ...matched, id: nextKey }; 112 + const matchedState: MatchedRouteState = { 113 + ...matched, 114 + id: nextKey, 115 + scrollPos: undefined, 116 + }; 109 117 110 118 const next: Record<string, MatchedRouteState> = { [nextKey]: matchedState }; 111 119 ··· 118 126 } 119 127 120 128 _cleanup = createRoot((cleanup) => { 121 - createEventListener; 122 - 123 129 onCleanup( 124 130 history.listen(({ action, location: nextEntry }) => { 125 131 const currentEntry = _entry; ··· 127 133 128 134 if (action !== 'update') { 129 135 const pathname = nextEntry.pathname; 130 - let matched = matchRoute(pathname); 136 + const matched = matchRoute(pathname); 131 137 132 138 if (!matched) { 133 139 return; ··· 139 145 let singles = current.singles; 140 146 let isNew = false; 141 147 148 + const prevId = current.active; 149 + 142 150 const nextId = matched.id || nextEntry.key; 143 - const matchedState: MatchedRouteState = { ...matched, id: nextId }; 151 + const matchedState: MatchedRouteState = { 152 + ...matched, 153 + id: nextId, 154 + scrollPos: undefined, 155 + }; 144 156 145 157 let nextViews: typeof views | undefined; 146 158 ··· 163 175 } 164 176 165 177 if (!matched.id) { 166 - // Add this view, if it's already present, set `shouldCall` to true 167 178 if (!(nextId in views)) { 168 179 if (nextViews) { 169 180 nextViews[nextId] = matchedState; 181 + isNew = true; 170 182 } else { 171 183 nextViews = { ...views, [nextId]: matchedState }; 172 184 isNew = true; 173 185 } 174 186 } 175 187 } else { 176 - // Add this view, if it's already present, set `shouldCall` to true 177 188 if (!(nextId in singles)) { 178 189 singles = { ...singles, [nextId]: matchedState }; 179 190 isNew = true; ··· 184 195 views = nextViews; 185 196 } 186 197 187 - routerEvents.emit(current.active, { focus: false, enter: false }); 198 + { 199 + const prev = current.views[prevId] || current.singles[prevId]; 200 + if (prev) { 201 + prev.scrollPos = { x: window.scrollX, y: window.scrollY }; 202 + } 203 + } 204 + 205 + routerEvents.emit(prevId, { focus: false, enter: false }); 206 + 188 207 setState({ active: nextId, views: views, singles: singles }); 189 208 190 - if (!isNew) { 209 + if (isNew) { 210 + // Scroll to top if we're pushing or replacing, it's a new page. 211 + window.scrollTo(0, 0); 212 + } else { 213 + { 214 + const next = views[nextId] || singles[nextId]; 215 + if (next?.scrollPos) { 216 + const pos = next.scrollPos; 217 + window.scrollTo(pos.x, pos.y); 218 + } 219 + } 220 + 191 221 routerEvents.emit(nextId, { 192 222 focus: true, 193 223 enter: action !== 'traverse' || nextEntry.index > currentEntry.index, 194 224 }); 195 - } 196 - 197 - // Scroll to top if we're pushing or replacing, it's a new page. 198 - if (!matched.id && (action === 'push' || action === 'replace')) { 199 - window.scrollTo({ top: 0, behavior: 'instant' }); 200 225 } 201 226 } 202 227 }), ··· 235 260 } 236 261 237 262 evt.preventDefault(); 238 - 239 - if (location.pathname !== pathname || location.search !== search || location.hash !== hash) { 240 - history.navigate({ pathname, search, hash }); 241 - } 263 + history.navigate({ pathname, search, hash }); 242 264 }); 243 265 244 266 return cleanup; ··· 273 295 }; 274 296 275 297 export const onRouteEnter = (cb: () => void) => { 276 - const { route } = useViewContext(); 298 + const { route, isActive } = useViewContext(); 277 299 278 - cb(); 300 + if (untrack(isActive)) { 301 + cb(); 302 + } 303 + 279 304 onCleanup(routerEvents.on(route.id, (e) => e.enter && cb())); 305 + }; 306 + 307 + export const useIsFocused = (): Accessor<boolean> => { 308 + const { isActive } = useViewContext(); 309 + 310 + return isActive; 280 311 }; 281 312 282 313 export const createFocusEffect = (cb: () => void) => { 283 - const { route } = useViewContext(); 284 - const [active, setActive] = createSignal(true); 314 + const isFocused = useIsFocused(); 285 315 286 - onCleanup(routerEvents.on(route.id, (e) => setActive(e.focus))); 287 316 createEffect(() => { 288 - if (active()) { 289 - cb(); 317 + if (isFocused()) { 318 + createEffect(cb); 290 319 } 291 320 }); 292 321 }; ··· 305 334 const render = props.render; 306 335 307 336 const renderView = (matched: MatchedRouteState) => { 308 - const def = matched.def; 309 337 const id = matched.id; 310 338 311 339 const active = createMemo((): boolean => state().active === id); ··· 313 341 const context: ViewContextObject = { 314 342 owner: getOwner(), 315 343 route: matched, 344 + isActive: active, 316 345 }; 317 - 318 - if (def.single) { 319 - let storedHeight: number | undefined; 320 - 321 - onCleanup( 322 - routerEvents.on(id, (ev) => { 323 - if (!ev.focus) { 324 - storedHeight = document.documentElement.scrollTop; 325 - } else if (storedHeight !== undefined) { 326 - window.scrollTo({ top: storedHeight, behavior: 'instant' }); 327 - } 328 - }), 329 - ); 330 - } 331 346 332 347 return ( 333 348 <Freeze freeze={!active()}>