@pmate/bridge 0.1.11 → 0.1.12
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/README.md +29 -0
- package/package.json +14 -4
- package/src/navigation/core.ts +62 -0
- package/src/navigation/index.ts +11 -0
package/README.md
CHANGED
|
@@ -43,3 +43,32 @@ await startup.ready()
|
|
|
43
43
|
```
|
|
44
44
|
|
|
45
45
|
`startup.getState()` and `startup.ready()` resolve `{ supported, visible }`. The first ready call closes the native launch splash; repeat calls, OAuth returns and foreground transitions cannot reopen it. Auth must report ready when an interactive login form or terminal error is rendered, and must not report ready during a silent redirect. Non-native environments and hosts explicitly rejecting this optional method return unsupported; other failures are surfaced. Hosts enforce their trusted-page bridge boundary and provide an actionable startup failure when readiness never arrives.
|
|
46
|
+
|
|
47
|
+
## Navigation (optional React Router integration)
|
|
48
|
+
|
|
49
|
+
### Background
|
|
50
|
+
|
|
51
|
+
Most links should stay within the current WebView and use the application's Router. A few hierarchical pages benefit from native transitions and interactive back gestures. The independent `@pmate/bridge/navigation` entry integrates React Router without importing it from the main Bridge entry. Install React and React Router `^6.22.1 || ^7.0.0` in the consuming application; React Router is an optional peer for consumers that do not use this entry.
|
|
52
|
+
|
|
53
|
+
```tsx
|
|
54
|
+
import { useNavigation } from "@pmate/bridge/navigation"
|
|
55
|
+
|
|
56
|
+
function ChatLink() {
|
|
57
|
+
const navigation = useNavigation() // inside your React Router
|
|
58
|
+
return <button onClick={() => {
|
|
59
|
+
void navigation.navigate("/chat/123", { presentation: "push" }).catch(showError)
|
|
60
|
+
}}>Open chat</button>
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
- `navigate(to, options?)` defaults to `presentation: "current"`, preserving React Router To/options, relative routes, replace and history state.
|
|
65
|
+
- `navigate("/chat/123?tab=messages#latest", { presentation: "push" })` requests a new native page, preserving the Router basename/hash href. Native push requires a root-relative application path (not an external URL or route-relative path); it accepts no Router-only options such as `state`, `relative` or `replace`. New pages must restore everything they need from their URL and shared application session.
|
|
66
|
+
- `replace(to, options?)` replaces the current Router entry.
|
|
67
|
+
- `back()` navigates back in the current Router only.
|
|
68
|
+
- `close()` pops the current native page. In browsers, older hosts or the native root, it invokes Router back. Applications must choose a suitable destination for a root back button rather than relying on history always containing a previous entry.
|
|
69
|
+
|
|
70
|
+
All methods return `Promise<void>` so callers can handle native failures. The fallback is used only when there is no bridge, `navigation.getState` explicitly reports unsupported, or an older host explicitly reports that method missing. Unexpected errors, malformed responses and failed native actions reject; they never trigger a second Router navigation. Repeated in-flight native pushes reject.
|
|
71
|
+
|
|
72
|
+
Native protocol: `navigation.getState` returns `{ supported, canClose }`; `navigation.push({url})` confirms `{opened:true}` and `navigation.close()` confirms `{closed:true}`. URL validation and document/frame origin checks also run natively. iOS hosts wrap their root `PmateWebViewController` in `PmatePageNavigationController`; Merry does this by default. Other iOS integrations without this wrapper report unsupported and retain Router behavior. Android pages use separate Activities in the existing task. Closing restores the retained parent WebView. This API does not synchronize business state between documents or convert existing Router links automatically.
|
|
73
|
+
|
|
74
|
+
Run `bun test packages/bridge/tests`, `pnpm exec tsc -p packages/bridge/tsconfig.json`, and `pnpm --filter @pmate/bridge exec playwright test` from the workspace.
|
package/package.json
CHANGED
|
@@ -1,25 +1,35 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pmate/bridge",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.12",
|
|
4
4
|
"files": [
|
|
5
5
|
"src",
|
|
6
6
|
"global.d.ts"
|
|
7
7
|
],
|
|
8
8
|
"types": "./global.d.ts",
|
|
9
9
|
"peerDependencies": {
|
|
10
|
-
"react": "*"
|
|
10
|
+
"react": "*",
|
|
11
|
+
"react-router": "^6.22.1 || ^7.0.0"
|
|
11
12
|
},
|
|
12
13
|
"peerDependenciesMeta": {
|
|
13
14
|
"react": {
|
|
14
15
|
"optional": true
|
|
16
|
+
},
|
|
17
|
+
"react-router": {
|
|
18
|
+
"optional": true
|
|
15
19
|
}
|
|
16
20
|
},
|
|
17
21
|
"devDependencies": {
|
|
18
22
|
"@types/react": "^19.2.7",
|
|
19
|
-
"react": "^19.1.1"
|
|
23
|
+
"react": "^19.1.1",
|
|
24
|
+
"react-router": "^6.22.1",
|
|
25
|
+
"@playwright/test": "1.60.0",
|
|
26
|
+
"react-dom": "^19.1.1",
|
|
27
|
+
"@remix-run/router": "^1.23.3",
|
|
28
|
+
"vite": "^7.3.1"
|
|
20
29
|
},
|
|
21
30
|
"main": "src/index.ts",
|
|
22
31
|
"exports": {
|
|
23
|
-
".": "./src/index.ts"
|
|
32
|
+
".": "./src/index.ts",
|
|
33
|
+
"./navigation": "./src/navigation/index.ts"
|
|
24
34
|
}
|
|
25
35
|
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { NavigateFunction, NavigateOptions, To } from "react-router"
|
|
2
|
+
|
|
3
|
+
export type NavigationOptions = NavigateOptions & { presentation?: "current" }
|
|
4
|
+
export type PushOptions = { presentation: "push" }
|
|
5
|
+
export interface Navigation {
|
|
6
|
+
navigate(to: To, options?: NavigationOptions): Promise<void>
|
|
7
|
+
navigate(to: string, options: PushOptions): Promise<void>
|
|
8
|
+
replace(to: To, options?: Omit<NavigateOptions, "replace">): Promise<void>
|
|
9
|
+
back(): Promise<void>
|
|
10
|
+
close(): Promise<void>
|
|
11
|
+
}
|
|
12
|
+
export type NavigationState = { supported: boolean; canClose: boolean }
|
|
13
|
+
type Bridge = { call(name: string, params?: unknown): Promise<unknown> }
|
|
14
|
+
|
|
15
|
+
function bridge(): Bridge | undefined {
|
|
16
|
+
return typeof window === "undefined" ? undefined : (window as Window & { PmateBridge?: Bridge }).PmateBridge
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function state(host: Bridge): Promise<NavigationState> {
|
|
20
|
+
try {
|
|
21
|
+
const result = await host.call("navigation.getState") as NavigationState
|
|
22
|
+
if (typeof result?.supported !== "boolean" || typeof result.canClose !== "boolean") throw new Error("Invalid navigation bridge response")
|
|
23
|
+
return result
|
|
24
|
+
} catch (error) {
|
|
25
|
+
if (error instanceof Error && error.message === "Bridge function not found: navigation.getState") return { supported: false, canClose: false }
|
|
26
|
+
throw error
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Router integration is supplied by the hook; the native protocol never imports React Router. */
|
|
31
|
+
export function createNavigation(routerNavigate: NavigateFunction, rootHref: string): Navigation {
|
|
32
|
+
let opening = false
|
|
33
|
+
return {
|
|
34
|
+
async navigate(to: To, options?: NavigationOptions | PushOptions) {
|
|
35
|
+
const { presentation = "current", ...routerOptions } = options ?? {}
|
|
36
|
+
if (presentation === "current") { await routerNavigate(to, routerOptions); return }
|
|
37
|
+
if (presentation !== "push") throw new Error("Invalid navigation presentation")
|
|
38
|
+
// A native page must be reconstructible from its URL, unlike history.state.
|
|
39
|
+
if (Object.keys(routerOptions).length) throw new Error("Native push does not accept Router options")
|
|
40
|
+
if (typeof to !== "string" || !to.startsWith("/") || to.startsWith("//") || to.includes("\\")) throw new Error("Native push requires an absolute application path starting with /")
|
|
41
|
+
if (opening) throw new Error("A native page is already opening")
|
|
42
|
+
opening = true
|
|
43
|
+
try {
|
|
44
|
+
const host = bridge()
|
|
45
|
+
if (!host || !(await state(host)).supported) { await routerNavigate(to); return }
|
|
46
|
+
const url = new URL(`${rootHref.replace(/\/?$/, "/")}${to.slice(1)}`, window.location.href)
|
|
47
|
+
if (url.origin !== window.location.origin || url.username || url.password) throw new Error("Native navigation must stay within the application origin")
|
|
48
|
+
const result = await host.call("navigation.push", { url: url.href }) as { opened: boolean }
|
|
49
|
+
if (result?.opened !== true) throw new Error("Native page opening was not confirmed")
|
|
50
|
+
} finally { opening = false }
|
|
51
|
+
},
|
|
52
|
+
async replace(to, options) { await routerNavigate(to, { ...options, replace: true }) },
|
|
53
|
+
async back() { await routerNavigate(-1) },
|
|
54
|
+
async close() {
|
|
55
|
+
const host = bridge()
|
|
56
|
+
const current = host ? await state(host) : undefined
|
|
57
|
+
if (!host || !current?.supported || !current.canClose) { await routerNavigate(-1); return }
|
|
58
|
+
const result = await host.call("navigation.close") as { closed: boolean }
|
|
59
|
+
if (result?.closed !== true) throw new Error("Native page closing was not confirmed")
|
|
60
|
+
},
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { useMemo } from "react"
|
|
2
|
+
import { useHref, useNavigate } from "react-router"
|
|
3
|
+
import { createNavigation } from "./core"
|
|
4
|
+
export type { Navigation, NavigationOptions, NavigationState, PushOptions } from "./core"
|
|
5
|
+
|
|
6
|
+
/** Must be used inside the application's React Router, including its basename/hash configuration. */
|
|
7
|
+
export function useNavigation() {
|
|
8
|
+
const navigate = useNavigate()
|
|
9
|
+
const rootHref = useHref("/")
|
|
10
|
+
return useMemo(() => createNavigation(navigate, rootHref), [navigate, rootHref])
|
|
11
|
+
}
|