@owlmeans/web-router 0.1.10 → 0.1.14
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/agent-meta/instructions/web-router.instructions.md +16 -7
- package/agent-meta/manifest.json +2 -2
- package/agent-meta/skills/web-router/SKILL.md +42 -9
- package/build/browser/chain.d.ts +16 -0
- package/build/browser/chain.d.ts.map +1 -0
- package/build/browser/chain.js +27 -0
- package/build/browser/chain.js.map +1 -0
- package/build/browser/context.d.ts +6 -0
- package/build/browser/context.d.ts.map +1 -0
- package/build/browser/context.js +4 -0
- package/build/browser/context.js.map +1 -0
- package/build/browser/history.d.ts +8 -0
- package/build/browser/history.d.ts.map +1 -0
- package/build/browser/history.js +49 -0
- package/build/browser/history.js.map +1 -0
- package/build/browser/hooks.d.ts +6 -0
- package/build/browser/hooks.d.ts.map +1 -0
- package/build/browser/hooks.js +29 -0
- package/build/browser/hooks.js.map +1 -0
- package/build/browser/outlet.d.ts +9 -0
- package/build/browser/outlet.d.ts.map +1 -0
- package/build/browser/outlet.js +14 -0
- package/build/browser/outlet.js.map +1 -0
- package/build/browser/plugin.d.ts +8 -0
- package/build/browser/plugin.d.ts.map +1 -0
- package/build/browser/plugin.js +27 -0
- package/build/browser/plugin.js.map +1 -0
- package/build/browser/provider.d.ts +11 -0
- package/build/browser/provider.d.ts.map +1 -0
- package/build/browser/provider.js +39 -0
- package/build/browser/provider.js.map +1 -0
- package/build/browser/types.d.ts +20 -0
- package/build/browser/types.d.ts.map +1 -0
- package/build/browser/types.js +2 -0
- package/build/browser/types.js.map +1 -0
- package/build/consts.d.ts +2 -0
- package/build/consts.d.ts.map +1 -0
- package/build/consts.js +2 -0
- package/build/consts.js.map +1 -0
- package/build/index.d.ts +8 -0
- package/build/index.d.ts.map +1 -1
- package/build/index.js +7 -0
- package/build/index.js.map +1 -1
- package/build/service.d.ts +13 -4
- package/build/service.d.ts.map +1 -1
- package/build/service.js +15 -19
- package/build/service.js.map +1 -1
- package/package.json +16 -9
- package/src/browser/chain.tsx +35 -0
- package/src/browser/context.ts +6 -0
- package/src/browser/history.ts +56 -0
- package/src/browser/hooks.ts +42 -0
- package/src/browser/outlet.tsx +16 -0
- package/src/browser/plugin.ts +29 -0
- package/src/browser/provider.tsx +53 -0
- package/src/browser/types.ts +22 -0
- package/src/consts.ts +2 -0
- package/src/index.ts +8 -0
- package/src/service.ts +20 -31
- package/tests/context.ts +33 -0
- package/tests/harness/index.html +11 -0
- package/tests/harness/mount.tsx +60 -0
- package/tests/routing.spec.ts +100 -0
- package/tsconfig.json +1 -1
package/build/service.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"service.js","sourceRoot":"","sources":["../src/service.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"service.js","sourceRoot":"","sources":["../src/service.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,mBAAmB,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAA;AACzE,OAAO,EAAE,uBAAuB,EAAE,MAAM,qBAAqB,CAAA;AAE7D;;;GAGG;AACH,MAAM,CAAC,MAAM,oBAAoB,GAAG,GAAkB,EAAE;IACtD,MAAM,OAAO,GAAG,iBAAiB,EAAE,CAAA;IACnC,OAAO,CAAC,cAAc,CAAC,uBAAuB,EAAE,CAAC,CAAA;IACjD,OAAO,OAAO,CAAA;AAChB,CAAC,CAAA;AAED;;;;GAIG;AACH,MAAM,CAAC,MAAM,eAAe,GAAG,CAAmD,GAAM,EAAK,EAAE;IAC7F,MAAM,OAAO,GAAG,mBAAmB,CAAC,GAAG,CAAC,CAAA;IACxC,OAAO,CAAC,cAAc,CAAC,uBAAuB,EAAE,CAAC,CAAA;IACjD,OAAO,GAAG,CAAA;AACZ,CAAC,CAAA"}
|
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@owlmeans/web-router",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.14",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"build": "tsc -b",
|
|
8
8
|
"dev": "sleep 63 && nodemon -e ts,tsx,json --watch src --exec \"tsc -p ./tsconfig.json\"",
|
|
9
|
-
"watch": "tsc -b -w --preserveWatchOutput --pretty"
|
|
9
|
+
"watch": "tsc -b -w --preserveWatchOutput --pretty",
|
|
10
|
+
"test": "bun test ./tests"
|
|
10
11
|
},
|
|
11
12
|
"main": "build/index.js",
|
|
12
13
|
"module": "build/index.js",
|
|
@@ -21,18 +22,24 @@
|
|
|
21
22
|
}
|
|
22
23
|
},
|
|
23
24
|
"peerDependencies": {
|
|
24
|
-
"react": "*"
|
|
25
|
-
"react-router": "^7.9.6"
|
|
25
|
+
"react": "*"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
28
|
"@owlmeans/dep-config": "workspace:*",
|
|
29
|
-
"@
|
|
30
|
-
"@types/
|
|
31
|
-
"
|
|
32
|
-
"
|
|
29
|
+
"@owlmeans/test-ui": "^0.1.14",
|
|
30
|
+
"@types/node": "^26.1.0",
|
|
31
|
+
"@types/react": "^19.2.17",
|
|
32
|
+
"@vitejs/plugin-react": "^6.0.3",
|
|
33
|
+
"nodemon": "^3.1.14",
|
|
34
|
+
"playwright": "^1.61.1",
|
|
35
|
+
"react": "^19.2.7",
|
|
36
|
+
"react-dom": "^19.2.7",
|
|
37
|
+
"typescript": "^6.0.3",
|
|
38
|
+
"vite": "^8.1.2"
|
|
33
39
|
},
|
|
34
40
|
"dependencies": {
|
|
35
|
-
"@owlmeans/
|
|
41
|
+
"@owlmeans/context": "^0.1.14",
|
|
42
|
+
"@owlmeans/router": "^0.1.14"
|
|
36
43
|
},
|
|
37
44
|
"publishConfig": {
|
|
38
45
|
"access": "public"
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { FC } from 'react'
|
|
2
|
+
import { createElement, useContext } from 'react'
|
|
3
|
+
import { RouterStateContext, OutletContext } from './context.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Renders the matched route chain starting at `depth`.
|
|
7
|
+
*
|
|
8
|
+
* A matched route that carries no `Component` is a **pass-through**: it renders
|
|
9
|
+
* the next-deeper match, exactly like react-router's implicit `<Outlet/>` for
|
|
10
|
+
* element-less routes. OwlMeans module trees depend on this — grouping modules
|
|
11
|
+
* (e.g. `client-authentication` → `client-authentication:authentication`) have
|
|
12
|
+
* no handler, so `@owlmeans/client` emits `RouteObject`s without a `Component`
|
|
13
|
+
* for them. Rendering only the exact node at `depth` would leave the whole
|
|
14
|
+
* subtree blank whenever a group sits above the screen module.
|
|
15
|
+
*/
|
|
16
|
+
export const RouteChain: FC<{ depth: number }> = ({ depth }) => {
|
|
17
|
+
const state = useContext(RouterStateContext)
|
|
18
|
+
const matches = state?.matches ?? []
|
|
19
|
+
|
|
20
|
+
let current = depth
|
|
21
|
+
while (current < matches.length && matches[current].route.Component == null) {
|
|
22
|
+
current++
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const Component = matches[current]?.route.Component
|
|
26
|
+
if (Component == null) {
|
|
27
|
+
return null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return createElement(
|
|
31
|
+
OutletContext.Provider,
|
|
32
|
+
{ value: { depth: current } },
|
|
33
|
+
createElement(Component)
|
|
34
|
+
)
|
|
35
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { Location } from '@owlmeans/router'
|
|
2
|
+
import type { BrowserHistory } from './types.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Minimal history wrapping the browser `window.history` + `popstate`. When no
|
|
6
|
+
* `window` is present (SSR) it returns an inert stub — the browser plugin is only
|
|
7
|
+
* *selected* in the browser, so this is purely defensive.
|
|
8
|
+
*/
|
|
9
|
+
export const createBrowserHistory = (): BrowserHistory => {
|
|
10
|
+
if (typeof window === 'undefined') {
|
|
11
|
+
const stub: Location = { pathname: '/', search: '', hash: '', state: null, key: '0' }
|
|
12
|
+
return {
|
|
13
|
+
get location() { return stub },
|
|
14
|
+
push: () => { }, replace: () => { }, go: () => { }, listen: () => () => { }
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const listeners = new Set<(location: Location) => void>()
|
|
19
|
+
let counter = 0
|
|
20
|
+
|
|
21
|
+
const currentLocation = (): Location => ({
|
|
22
|
+
pathname: window.location.pathname,
|
|
23
|
+
search: window.location.search,
|
|
24
|
+
hash: window.location.hash,
|
|
25
|
+
state: window.history.state ?? null,
|
|
26
|
+
key: String(counter)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
const notify = () => {
|
|
30
|
+
const location = currentLocation()
|
|
31
|
+
listeners.forEach(callback => callback(location))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
window.addEventListener('popstate', () => {
|
|
35
|
+
counter++
|
|
36
|
+
notify()
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
get location() { return currentLocation() },
|
|
41
|
+
push: (to, state) => {
|
|
42
|
+
counter++
|
|
43
|
+
window.history.pushState(state ?? null, '', to)
|
|
44
|
+
notify()
|
|
45
|
+
},
|
|
46
|
+
replace: (to, state) => {
|
|
47
|
+
window.history.replaceState(state ?? null, '', to)
|
|
48
|
+
notify()
|
|
49
|
+
},
|
|
50
|
+
go: delta => { window.history.go(delta) },
|
|
51
|
+
listen: callback => {
|
|
52
|
+
listeners.add(callback)
|
|
53
|
+
return () => { listeners.delete(callback) }
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { useContext, useMemo } from 'react'
|
|
2
|
+
import type {
|
|
3
|
+
Location, NavigateFunction, RouteParams, SetSearchParams, UseSearchParamsHook
|
|
4
|
+
} from '@owlmeans/router'
|
|
5
|
+
import { RouterStateContext } from './context.js'
|
|
6
|
+
|
|
7
|
+
const useRouterState = () => {
|
|
8
|
+
const state = useContext(RouterStateContext)
|
|
9
|
+
if (state == null) {
|
|
10
|
+
throw new Error('owlmeans-browser-router: router hook used outside of the router provider')
|
|
11
|
+
}
|
|
12
|
+
return state
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const useParams = <T extends RouteParams = RouteParams>(): T => {
|
|
16
|
+
const { matches } = useRouterState()
|
|
17
|
+
return (matches[matches.length - 1]?.params ?? {}) as T
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const useLocation = (): Location => useRouterState().location
|
|
21
|
+
|
|
22
|
+
export const useNavigate = (): NavigateFunction => useRouterState().navigate
|
|
23
|
+
|
|
24
|
+
export const useSearchParams: UseSearchParamsHook = init => {
|
|
25
|
+
const { location, navigate } = useRouterState()
|
|
26
|
+
|
|
27
|
+
const search = useMemo(
|
|
28
|
+
() => new URLSearchParams(location.search !== '' ? location.search : (init ?? '')),
|
|
29
|
+
[location.search]
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
const setSearch: SetSearchParams = (next, options) => {
|
|
33
|
+
const resolved = typeof next === 'function'
|
|
34
|
+
? next(new URLSearchParams(location.search))
|
|
35
|
+
: next instanceof URLSearchParams ? next : new URLSearchParams(next)
|
|
36
|
+
const query = resolved.toString()
|
|
37
|
+
const path = `${location.pathname}${query !== '' ? `?${query}` : ''}${location.hash ?? ''}`
|
|
38
|
+
navigate(path, { replace: options?.replace, state: options?.state })
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return [search, setSearch]
|
|
42
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { FC } from 'react'
|
|
2
|
+
import { createElement, useContext } from 'react'
|
|
3
|
+
import { OutletContext } from './context.js'
|
|
4
|
+
import { RouteChain } from './chain.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Renders the next-deeper matched route. Depth is tracked via `OutletContext`, so
|
|
8
|
+
* nested `<Outlet/>`s walk down the match chain — composing with the renderer in
|
|
9
|
+
* `@owlmeans/client` (parent components emit `<Outlet/>`). Component-less matches
|
|
10
|
+
* in between are skipped by `RouteChain` (implicit pass-through).
|
|
11
|
+
*/
|
|
12
|
+
export const Outlet: FC = () => {
|
|
13
|
+
const { depth } = useContext(OutletContext)
|
|
14
|
+
|
|
15
|
+
return createElement(RouteChain, { depth: depth + 1 })
|
|
16
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { LibraryRouter, RouterPlugin } from '@owlmeans/router'
|
|
2
|
+
import { flattenRoutes, rankRouteBranches } from '@owlmeans/router'
|
|
3
|
+
import { BROWSER_ROUTER } from '../consts.js'
|
|
4
|
+
import { BrowserRouterProvider } from './provider.js'
|
|
5
|
+
import { Outlet } from './outlet.js'
|
|
6
|
+
import { useLocation, useNavigate, useParams, useSearchParams } from './hooks.js'
|
|
7
|
+
import type { OwlLibraryRouter } from './types.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The default OwlMeans in-browser routing plugin. Standard URL routing over the
|
|
11
|
+
* browser History API. Matches always (priority 0), so it is the universal
|
|
12
|
+
* fallback in the browser; a higher-priority SSR plugin can outrank it later.
|
|
13
|
+
*/
|
|
14
|
+
export const makeBrowserRouterPlugin = (): RouterPlugin => ({
|
|
15
|
+
alias: BROWSER_ROUTER,
|
|
16
|
+
priority: 0,
|
|
17
|
+
mode: 'browser',
|
|
18
|
+
match: () => true,
|
|
19
|
+
compile: routes => ({
|
|
20
|
+
routes,
|
|
21
|
+
branches: rankRouteBranches(flattenRoutes(routes))
|
|
22
|
+
} satisfies OwlLibraryRouter as LibraryRouter),
|
|
23
|
+
provider: () => BrowserRouterProvider,
|
|
24
|
+
outlet: () => Outlet,
|
|
25
|
+
useParams,
|
|
26
|
+
useLocation,
|
|
27
|
+
useNavigate,
|
|
28
|
+
useSearchParams
|
|
29
|
+
})
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { FC } from 'react'
|
|
2
|
+
import { createElement, useEffect, useMemo, useRef, useState } from 'react'
|
|
3
|
+
import type { LibraryRouter, Location, NavigateFunction } from '@owlmeans/router'
|
|
4
|
+
import { matchRoutes } from '@owlmeans/router'
|
|
5
|
+
import { createBrowserHistory } from './history.js'
|
|
6
|
+
import { RouterStateContext } from './context.js'
|
|
7
|
+
import { RouteChain } from './chain.js'
|
|
8
|
+
import type { OwlLibraryRouter } from './types.js'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Renders the OwlMeans library router. Subscribes to history, recomputes the
|
|
12
|
+
* matched route chain on navigation, and renders the top match. Initial render
|
|
13
|
+
* matches synchronously from `window.location` to avoid a first-paint flash.
|
|
14
|
+
*/
|
|
15
|
+
export const BrowserRouterProvider: FC<{ router: LibraryRouter }> = ({ router }) => {
|
|
16
|
+
const lib = router as OwlLibraryRouter
|
|
17
|
+
const historyRef = useRef(createBrowserHistory())
|
|
18
|
+
const history = historyRef.current
|
|
19
|
+
|
|
20
|
+
const [location, setLocation] = useState<Location>(() => history.location)
|
|
21
|
+
|
|
22
|
+
useEffect(() => history.listen(setLocation), [])
|
|
23
|
+
|
|
24
|
+
const matches = useMemo(
|
|
25
|
+
() => matchRoutes(lib.branches, location.pathname) ?? [],
|
|
26
|
+
[lib, location.pathname]
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
const navigate = useMemo<NavigateFunction>(() => ((to: string | number | { pathname?: string, search?: string, hash?: string }, options?: { replace?: boolean, state?: any }) => {
|
|
30
|
+
if (typeof to === 'number') {
|
|
31
|
+
history.go(to)
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
if (typeof to === 'string' && to.startsWith('http')) {
|
|
35
|
+
window.location.href = to
|
|
36
|
+
return
|
|
37
|
+
}
|
|
38
|
+
const path = typeof to === 'string'
|
|
39
|
+
? to
|
|
40
|
+
: `${to.pathname ?? ''}${to.search ?? ''}${to.hash ?? ''}`
|
|
41
|
+
if (options?.replace) {
|
|
42
|
+
history.replace(path, options.state)
|
|
43
|
+
} else {
|
|
44
|
+
history.push(path, options?.state)
|
|
45
|
+
}
|
|
46
|
+
}) as NavigateFunction, [history])
|
|
47
|
+
|
|
48
|
+
return createElement(
|
|
49
|
+
RouterStateContext.Provider,
|
|
50
|
+
{ value: { location, matches, navigate } },
|
|
51
|
+
createElement(RouteChain, { depth: 0 })
|
|
52
|
+
)
|
|
53
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Location, NavigateFunction, RouteMatch, RouteObject, RouteBranch } from '@owlmeans/router'
|
|
2
|
+
|
|
3
|
+
/** The compiled library router produced by the browser plugin's `compile`. */
|
|
4
|
+
export interface OwlLibraryRouter {
|
|
5
|
+
routes: RouteObject[]
|
|
6
|
+
branches: RouteBranch[]
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Value carried by the router state React context. */
|
|
10
|
+
export interface RouterState {
|
|
11
|
+
location: Location
|
|
12
|
+
matches: RouteMatch[]
|
|
13
|
+
navigate: NavigateFunction
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface BrowserHistory {
|
|
17
|
+
readonly location: Location
|
|
18
|
+
push: (to: string, state?: any) => void
|
|
19
|
+
replace: (to: string, state?: any) => void
|
|
20
|
+
go: (delta: number) => void
|
|
21
|
+
listen: (callback: (location: Location) => void) => () => void
|
|
22
|
+
}
|
package/src/consts.ts
ADDED
package/src/index.ts
CHANGED
|
@@ -1,2 +1,10 @@
|
|
|
1
1
|
|
|
2
|
+
export * from './consts.js'
|
|
2
3
|
export * from './service.js'
|
|
4
|
+
export * from './browser/plugin.js'
|
|
5
|
+
export type * from './browser/types.js'
|
|
6
|
+
export { BrowserRouterProvider } from './browser/provider.js'
|
|
7
|
+
export { Outlet } from './browser/outlet.js'
|
|
8
|
+
export { RouteChain } from './browser/chain.js'
|
|
9
|
+
export { createBrowserHistory } from './browser/history.js'
|
|
10
|
+
export { useParams, useLocation, useNavigate, useSearchParams } from './browser/hooks.js'
|
package/src/service.ts
CHANGED
|
@@ -1,36 +1,25 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
1
|
+
import type { BasicConfig, BasicContext } from "@owlmeans/context"
|
|
2
|
+
import type { RouterService } from "@owlmeans/router"
|
|
3
|
+
import { ensureRouterService, makeRouterService } from "@owlmeans/router"
|
|
4
|
+
import { makeBrowserRouterPlugin } from "./browser/plugin.js"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Build a router service pre-loaded with the OwlMeans browser plugin. Kept for
|
|
8
|
+
* back-compat; prefer `appendWebRouter` which reuses an existing host.
|
|
9
|
+
*/
|
|
10
|
+
export const makeWebRouterService = (): RouterService => {
|
|
9
11
|
const service = makeRouterService()
|
|
10
|
-
|
|
11
|
-
service.outlet = () => {
|
|
12
|
-
return Outlet
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
service.provider = () => {
|
|
16
|
-
return RouterProvider
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
service.useParams = <T extends RouteParams = RouteParams>() => {
|
|
20
|
-
return useParams() as T
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
service.useLocation = () => {
|
|
24
|
-
return useLocation()
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
service.useNavigate = () => {
|
|
28
|
-
return useNavigate()
|
|
29
|
-
}
|
|
30
|
-
|
|
12
|
+
service.registerPlugin(makeBrowserRouterPlugin())
|
|
31
13
|
return service
|
|
32
14
|
}
|
|
33
15
|
|
|
34
|
-
|
|
35
|
-
|
|
16
|
+
/**
|
|
17
|
+
* Register the OwlMeans in-browser routing plugin as the default on a context.
|
|
18
|
+
* Idempotent w.r.t. the router host — if another plugin (e.g. react-router) is
|
|
19
|
+
* already registered, this one is added alongside and selected by the cascade.
|
|
20
|
+
*/
|
|
21
|
+
export const appendWebRouter = <C extends BasicConfig, T extends BasicContext<C>>(ctx: T): T => {
|
|
22
|
+
const service = ensureRouterService(ctx)
|
|
23
|
+
service.registerPlugin(makeBrowserRouterPlugin())
|
|
24
|
+
return ctx
|
|
36
25
|
}
|
package/tests/context.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { createServer } from 'vite'
|
|
2
|
+
import react from '@vitejs/plugin-react'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
import { dirname, resolve } from 'node:path'
|
|
5
|
+
|
|
6
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
7
|
+
|
|
8
|
+
let url: string | null = null
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Boot a Vite dev server serving `tests/harness/` (a real HTTP origin — the
|
|
12
|
+
* OwlMeans router needs the History API, which a `data:` URL cannot provide).
|
|
13
|
+
* One server per `bun test` process; the harness renders the routing app.
|
|
14
|
+
*/
|
|
15
|
+
export const getHarnessUrl = async (): Promise<string> => {
|
|
16
|
+
if (url != null) return url
|
|
17
|
+
const server = await createServer({
|
|
18
|
+
configFile: false,
|
|
19
|
+
root: resolve(here, './harness'),
|
|
20
|
+
plugins: [react()],
|
|
21
|
+
// Single React instance so hooks work across the workspace-linked plugin.
|
|
22
|
+
resolve: { dedupe: ['react', 'react-dom'] },
|
|
23
|
+
server: { port: 0 },
|
|
24
|
+
logLevel: 'warn'
|
|
25
|
+
})
|
|
26
|
+
await server.listen()
|
|
27
|
+
const local = server.resolvedUrls?.local?.[0]
|
|
28
|
+
if (local == null) throw new Error('vite did not expose a local URL')
|
|
29
|
+
url = local
|
|
30
|
+
return url
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const HARNESS_URL = await getHarnessUrl()
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { createElement as h } from 'react'
|
|
2
|
+
import { createRoot } from 'react-dom/client'
|
|
3
|
+
import type { RouteObject } from '@owlmeans/router'
|
|
4
|
+
import {
|
|
5
|
+
makeBrowserRouterPlugin, Outlet, useNavigate, useParams, useSearchParams
|
|
6
|
+
} from '@owlmeans/web-router'
|
|
7
|
+
|
|
8
|
+
// A small but real app driven by the OwlMeans in-browser routing plugin. The
|
|
9
|
+
// e2e spec navigates this in a real chromium and asserts against the rendered
|
|
10
|
+
// DOM + the browser URL/history.
|
|
11
|
+
|
|
12
|
+
const Nav = () => {
|
|
13
|
+
const navigate = useNavigate()
|
|
14
|
+
return h('nav', null,
|
|
15
|
+
h('button', { id: 'nav-home', onClick: () => navigate('/') }, 'home'),
|
|
16
|
+
h('button', { id: 'nav-users', onClick: () => navigate('/users') }, 'users'),
|
|
17
|
+
h('button', { id: 'nav-user', onClick: () => navigate('/users/42?token=abc') }, 'user-42'),
|
|
18
|
+
h('button', { id: 'nav-settings', onClick: () => navigate('/users/settings') }, 'settings')
|
|
19
|
+
)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const Layout = () => h('div', { id: 'layout' }, h('h1', null, 'app'), h(Nav), h(Outlet))
|
|
23
|
+
const Home = () => h('div', { id: 'home' }, 'home-screen')
|
|
24
|
+
const Users = () => h('div', { id: 'users' }, 'users-screen', h(Outlet))
|
|
25
|
+
const UsersIndex = () => h('div', { id: 'users-index' }, 'users-index-screen')
|
|
26
|
+
const Settings = () => h('div', { id: 'settings' }, 'settings-screen')
|
|
27
|
+
const Leaf = () => h('div', { id: 'leaf' }, 'leaf-screen')
|
|
28
|
+
const Deep = () => h('div', { id: 'deep' }, 'deep-screen')
|
|
29
|
+
const User = () => {
|
|
30
|
+
const { id } = useParams()
|
|
31
|
+
const [query] = useSearchParams()
|
|
32
|
+
return h('div', { id: 'user' }, `user:${id}:token:${query.get('token') ?? 'none'}`)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const routes: RouteObject[] = [
|
|
36
|
+
{
|
|
37
|
+
path: '', Component: Layout, children: [
|
|
38
|
+
{ index: true, Component: Home },
|
|
39
|
+
{
|
|
40
|
+
path: 'users', Component: Users, children: [
|
|
41
|
+
{ index: true, Component: UsersIndex },
|
|
42
|
+
{ path: 'settings', Component: Settings }, // static must outrank :id
|
|
43
|
+
// component-less grouping node between two rendered routes: <Outlet/> must
|
|
44
|
+
// fall through it instead of stopping (react-router's implicit outlet)
|
|
45
|
+
{ path: 'nested', children: [{ path: 'deep', Component: Deep }] },
|
|
46
|
+
{ path: ':id', Component: User }
|
|
47
|
+
]
|
|
48
|
+
}
|
|
49
|
+
]
|
|
50
|
+
},
|
|
51
|
+
// component-less grouping node at the very top of the chain — this is the shape
|
|
52
|
+
// `@owlmeans/client` emits for modules without a handler (e.g. `client-authentication`)
|
|
53
|
+
{ path: 'group', children: [{ path: 'leaf', Component: Leaf }] }
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
const plugin = makeBrowserRouterPlugin()
|
|
57
|
+
const Provider = plugin.provider()
|
|
58
|
+
const router = plugin.compile(routes)
|
|
59
|
+
|
|
60
|
+
createRoot(document.getElementById('root')!).render(h(Provider, { router }))
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { afterAll, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { closeBrowser, withPage } from '@owlmeans/test-ui'
|
|
3
|
+
import { HARNESS_URL } from './context.js'
|
|
4
|
+
|
|
5
|
+
const at = (path: string) => new URL(path, HARNESS_URL).toString()
|
|
6
|
+
|
|
7
|
+
afterAll(async () => { await closeBrowser() })
|
|
8
|
+
|
|
9
|
+
describe('@owlmeans/web-router — OwlMeans browser routing (chromium e2e)', () => {
|
|
10
|
+
test('renders the index route inside the layout at /', async () => {
|
|
11
|
+
await withPage(async page => {
|
|
12
|
+
await page.goto(HARNESS_URL)
|
|
13
|
+
await page.waitForSelector('#home')
|
|
14
|
+
expect(await page.locator('#layout').isVisible()).toBe(true)
|
|
15
|
+
expect(await page.locator('#home').textContent()).toContain('home-screen')
|
|
16
|
+
})
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
test('programmatic navigate resolves nested route, params and search', async () => {
|
|
20
|
+
await withPage(async page => {
|
|
21
|
+
await page.goto(HARNESS_URL)
|
|
22
|
+
await page.waitForSelector('#nav-user')
|
|
23
|
+
await page.click('#nav-user')
|
|
24
|
+
await page.waitForSelector('#user')
|
|
25
|
+
// real browser URL updated via the History API
|
|
26
|
+
expect(new URL(page.url()).pathname).toBe('/users/42')
|
|
27
|
+
expect(new URL(page.url()).search).toBe('?token=abc')
|
|
28
|
+
// nested outlet renders parent + child
|
|
29
|
+
expect(await page.locator('#users').textContent()).toContain('users-screen')
|
|
30
|
+
// useParams + useSearchParams resolve
|
|
31
|
+
expect(await page.locator('#user').textContent()).toBe('user:42:token:abc')
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('nested index route renders at /users', async () => {
|
|
36
|
+
await withPage(async page => {
|
|
37
|
+
await page.goto(HARNESS_URL)
|
|
38
|
+
await page.waitForSelector('#nav-users')
|
|
39
|
+
await page.click('#nav-users')
|
|
40
|
+
await page.waitForSelector('#users-index')
|
|
41
|
+
expect(new URL(page.url()).pathname).toBe('/users')
|
|
42
|
+
expect(await page.locator('#users-index').textContent()).toContain('users-index-screen')
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('static segment outranks the :id dynamic sibling', async () => {
|
|
47
|
+
await withPage(async page => {
|
|
48
|
+
await page.goto(at('users/settings'))
|
|
49
|
+
await page.waitForSelector('#settings')
|
|
50
|
+
expect(await page.locator('#settings').textContent()).toContain('settings-screen')
|
|
51
|
+
expect(await page.locator('#user').count()).toBe(0)
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('deep-links directly to a :param URL (synchronous initial match)', async () => {
|
|
56
|
+
await withPage(async page => {
|
|
57
|
+
await page.goto(at('users/7?token=xyz'))
|
|
58
|
+
await page.waitForSelector('#user')
|
|
59
|
+
expect(await page.locator('#user').textContent()).toBe('user:7:token:xyz')
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('renders through a component-less group at the top of the chain', async () => {
|
|
64
|
+
await withPage(async page => {
|
|
65
|
+
await page.goto(at('group/leaf'))
|
|
66
|
+
await page.waitForSelector('#leaf')
|
|
67
|
+
expect(await page.locator('#leaf').textContent()).toContain('leaf-screen')
|
|
68
|
+
// the group carries no Component — it must not swallow the whole subtree
|
|
69
|
+
expect(await page.locator('#layout').count()).toBe(0)
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('outlet falls through a component-less group in the middle of the chain', async () => {
|
|
74
|
+
await withPage(async page => {
|
|
75
|
+
await page.goto(at('users/nested/deep'))
|
|
76
|
+
await page.waitForSelector('#deep')
|
|
77
|
+
expect(await page.locator('#layout').isVisible()).toBe(true)
|
|
78
|
+
expect(await page.locator('#users').textContent()).toContain('users-screen')
|
|
79
|
+
expect(await page.locator('#deep').textContent()).toContain('deep-screen')
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test('browser back/forward restores the matched route', async () => {
|
|
84
|
+
await withPage(async page => {
|
|
85
|
+
await page.goto(HARNESS_URL)
|
|
86
|
+
await page.waitForSelector('#home')
|
|
87
|
+
await page.click('#nav-user')
|
|
88
|
+
await page.waitForSelector('#user')
|
|
89
|
+
|
|
90
|
+
await page.goBack()
|
|
91
|
+
await page.waitForSelector('#home')
|
|
92
|
+
expect(new URL(page.url()).pathname).toBe('/')
|
|
93
|
+
|
|
94
|
+
await page.goForward()
|
|
95
|
+
await page.waitForSelector('#user')
|
|
96
|
+
expect(new URL(page.url()).pathname).toBe('/users/42')
|
|
97
|
+
expect(await page.locator('#user').textContent()).toBe('user:42:token:abc')
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
})
|
package/tsconfig.json
CHANGED