@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.
Files changed (64) hide show
  1. package/agent-meta/instructions/web-router.instructions.md +16 -7
  2. package/agent-meta/manifest.json +2 -2
  3. package/agent-meta/skills/web-router/SKILL.md +42 -9
  4. package/build/browser/chain.d.ts +16 -0
  5. package/build/browser/chain.d.ts.map +1 -0
  6. package/build/browser/chain.js +27 -0
  7. package/build/browser/chain.js.map +1 -0
  8. package/build/browser/context.d.ts +6 -0
  9. package/build/browser/context.d.ts.map +1 -0
  10. package/build/browser/context.js +4 -0
  11. package/build/browser/context.js.map +1 -0
  12. package/build/browser/history.d.ts +8 -0
  13. package/build/browser/history.d.ts.map +1 -0
  14. package/build/browser/history.js +49 -0
  15. package/build/browser/history.js.map +1 -0
  16. package/build/browser/hooks.d.ts +6 -0
  17. package/build/browser/hooks.d.ts.map +1 -0
  18. package/build/browser/hooks.js +29 -0
  19. package/build/browser/hooks.js.map +1 -0
  20. package/build/browser/outlet.d.ts +9 -0
  21. package/build/browser/outlet.d.ts.map +1 -0
  22. package/build/browser/outlet.js +14 -0
  23. package/build/browser/outlet.js.map +1 -0
  24. package/build/browser/plugin.d.ts +8 -0
  25. package/build/browser/plugin.d.ts.map +1 -0
  26. package/build/browser/plugin.js +27 -0
  27. package/build/browser/plugin.js.map +1 -0
  28. package/build/browser/provider.d.ts +11 -0
  29. package/build/browser/provider.d.ts.map +1 -0
  30. package/build/browser/provider.js +39 -0
  31. package/build/browser/provider.js.map +1 -0
  32. package/build/browser/types.d.ts +20 -0
  33. package/build/browser/types.d.ts.map +1 -0
  34. package/build/browser/types.js +2 -0
  35. package/build/browser/types.js.map +1 -0
  36. package/build/consts.d.ts +2 -0
  37. package/build/consts.d.ts.map +1 -0
  38. package/build/consts.js +2 -0
  39. package/build/consts.js.map +1 -0
  40. package/build/index.d.ts +8 -0
  41. package/build/index.d.ts.map +1 -1
  42. package/build/index.js +7 -0
  43. package/build/index.js.map +1 -1
  44. package/build/service.d.ts +13 -4
  45. package/build/service.d.ts.map +1 -1
  46. package/build/service.js +15 -19
  47. package/build/service.js.map +1 -1
  48. package/package.json +16 -9
  49. package/src/browser/chain.tsx +35 -0
  50. package/src/browser/context.ts +6 -0
  51. package/src/browser/history.ts +56 -0
  52. package/src/browser/hooks.ts +42 -0
  53. package/src/browser/outlet.tsx +16 -0
  54. package/src/browser/plugin.ts +29 -0
  55. package/src/browser/provider.tsx +53 -0
  56. package/src/browser/types.ts +22 -0
  57. package/src/consts.ts +2 -0
  58. package/src/index.ts +8 -0
  59. package/src/service.ts +20 -31
  60. package/tests/context.ts +33 -0
  61. package/tests/harness/index.html +11 -0
  62. package/tests/harness/mount.tsx +60 -0
  63. package/tests/routing.spec.ts +100 -0
  64. package/tsconfig.json +1 -1
@@ -1 +1 @@
1
- {"version":3,"file":"service.js","sourceRoot":"","sources":["../src/service.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAA;AACpD,OAAO,EAAE,cAAc,EAAE,WAAW,EAAE,MAAM,cAAc,CAAA;AAC1D,OAAO,EAAE,MAAM,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,cAAc,CAAA;AAI7D,MAAM,CAAC,MAAM,oBAAoB,GAAG,GAAG,EAAE;IACvC,MAAM,OAAO,GAAG,iBAAiB,EAAE,CAAA;IAEnC,OAAO,CAAC,MAAM,GAAG,GAAG,EAAE;QACpB,OAAO,MAAM,CAAA;IACf,CAAC,CAAA;IAED,OAAO,CAAC,QAAQ,GAAG,GAAG,EAAE;QACtB,OAAO,cAAc,CAAA;IACvB,CAAC,CAAA;IAED,OAAO,CAAC,SAAS,GAAG,GAAwC,EAAE;QAC5D,OAAO,SAAS,EAAO,CAAA;IACzB,CAAC,CAAA;IAED,OAAO,CAAC,WAAW,GAAG,GAAG,EAAE;QACzB,OAAO,WAAW,EAAE,CAAA;IACtB,CAAC,CAAA;IAED,OAAO,CAAC,WAAW,GAAG,GAAG,EAAE;QACzB,OAAO,WAAW,EAAE,CAAA;IACtB,CAAC,CAAA;IAED,OAAO,OAAO,CAAA;AAChB,CAAC,CAAA;AAED,MAAM,CAAC,MAAM,eAAe,GAAG,CAAwE,GAAM,EAAE,EAAE;IAC/G,GAAG,CAAC,eAAe,CAAC,oBAAoB,EAAE,CAAC,CAAA;AAC7C,CAAC,CAAA"}
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.10",
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
- "@types/node": "^24.10.1",
30
- "@types/react": "^19.2.7",
31
- "nodemon": "^3.1.11",
32
- "typescript": "^6.0.2"
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/router": "^0.1.10"
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,6 @@
1
+ import { createContext } from 'react'
2
+ import type { RouterState } from './types.js'
3
+
4
+ export const RouterStateContext = createContext<RouterState | null>(null)
5
+
6
+ export const OutletContext = createContext<{ depth: number }>({ depth: 0 })
@@ -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
@@ -0,0 +1,2 @@
1
+
2
+ export const BROWSER_ROUTER = 'owlmeans-browser-router'
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 { RouteParams } from "@owlmeans/router"
2
- import { makeRouterService } from "@owlmeans/router"
3
- import { RouterProvider, useNavigate } from "react-router"
4
- import { Outlet, useLocation, useParams } from "react-router"
5
- import type { ClientConfig } from "@owlmeans/client-context"
6
- import type { ClientContext } from "@owlmeans/client"
7
-
8
- export const makeWebRouterService = () => {
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
- export const appendWebRouter = <C extends ClientConfig, T extends ClientContext<C> = ClientContext<C>>(ctx: T) => {
35
- ctx.registerService(makeWebRouterService())
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
  }
@@ -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,11 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <title>@owlmeans/web-router routing e2e</title>
6
+ </head>
7
+ <body>
8
+ <div id="root"></div>
9
+ <script type="module" src="/mount.tsx"></script>
10
+ </body>
11
+ </html>
@@ -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
@@ -7,5 +7,5 @@
7
7
  "rootDir": "./src/",
8
8
  "outDir": "./build/"
9
9
  },
10
- "exclude": ["./dist/**/*", "./build/**/*", "./*.ts"]
10
+ "exclude": ["./dist/**/*", "./build/**/*", "./tests/**/*", "./*.ts"]
11
11
  }