@vkma.dev/router 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/dist/core/bridge-like.d.ts +25 -0
- package/dist/core/bridge-like.d.ts.map +1 -0
- package/dist/core/bridge-like.js +13 -0
- package/dist/core/bridge-like.js.map +1 -0
- package/dist/core/bridge-sync.d.ts +37 -0
- package/dist/core/bridge-sync.d.ts.map +1 -0
- package/dist/core/bridge-sync.js +58 -0
- package/dist/core/bridge-sync.js.map +1 -0
- package/dist/core/hash-params.d.ts +40 -0
- package/dist/core/hash-params.d.ts.map +1 -0
- package/dist/core/hash-params.js +60 -0
- package/dist/core/hash-params.js.map +1 -0
- package/dist/core/loose-route.js +9 -0
- package/dist/core/loose-route.js.map +1 -0
- package/dist/core/panels-stack.d.ts +28 -0
- package/dist/core/panels-stack.d.ts.map +1 -0
- package/dist/core/panels-stack.js +60 -0
- package/dist/core/panels-stack.js.map +1 -0
- package/dist/core/state-keys.d.ts +10 -0
- package/dist/core/state-keys.d.ts.map +1 -0
- package/dist/core/state-keys.js +21 -0
- package/dist/core/state-keys.js.map +1 -0
- package/dist/core/swipe-back.d.ts +25 -0
- package/dist/core/swipe-back.d.ts.map +1 -0
- package/dist/core/swipe-back.js +53 -0
- package/dist/core/swipe-back.js.map +1 -0
- package/dist/core/vk-history.d.ts +41 -0
- package/dist/core/vk-history.d.ts.map +1 -0
- package/dist/core/vk-history.js +115 -0
- package/dist/core/vk-history.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- package/dist/preset/app.d.ts +27 -0
- package/dist/preset/app.d.ts.map +1 -0
- package/dist/preset/app.js +110 -0
- package/dist/preset/app.js.map +1 -0
- package/dist/preset/builders.d.ts +209 -0
- package/dist/preset/builders.d.ts.map +1 -0
- package/dist/preset/builders.js +209 -0
- package/dist/preset/builders.js.map +1 -0
- package/dist/preset/define-panel.d.ts +77 -0
- package/dist/preset/define-panel.d.ts.map +1 -0
- package/dist/preset/define-panel.js +42 -0
- package/dist/preset/define-panel.js.map +1 -0
- package/dist/preset/define-view.d.ts +42 -0
- package/dist/preset/define-view.d.ts.map +1 -0
- package/dist/preset/define-view.js +43 -0
- package/dist/preset/define-view.js.map +1 -0
- package/dist/preset/inflate.d.ts +17 -0
- package/dist/preset/inflate.d.ts.map +1 -0
- package/dist/preset/inflate.js +40 -0
- package/dist/preset/inflate.js.map +1 -0
- package/dist/preset/navigator.d.ts +75 -0
- package/dist/preset/navigator.d.ts.map +1 -0
- package/dist/preset/navigator.js +116 -0
- package/dist/preset/navigator.js.map +1 -0
- package/dist/preset.d.ts +17 -0
- package/dist/preset.js +16 -0
- package/dist/react/hooks.d.ts +69 -0
- package/dist/react/hooks.d.ts.map +1 -0
- package/dist/react/hooks.js +101 -0
- package/dist/react/hooks.js.map +1 -0
- package/dist/react.d.ts +2 -0
- package/dist/react.js +1 -0
- package/dist/vkui/choreography.js +59 -0
- package/dist/vkui/choreography.js.map +1 -0
- package/dist/vkui/modals.d.ts +49 -0
- package/dist/vkui/modals.d.ts.map +1 -0
- package/dist/vkui/modals.js +116 -0
- package/dist/vkui/modals.js.map +1 -0
- package/dist/vkui/not-found.d.ts +9 -0
- package/dist/vkui/not-found.d.ts.map +1 -0
- package/dist/vkui/not-found.js +23 -0
- package/dist/vkui/not-found.js.map +1 -0
- package/dist/vkui/popouts.d.ts +34 -0
- package/dist/vkui/popouts.d.ts.map +1 -0
- package/dist/vkui/popouts.js +69 -0
- package/dist/vkui/popouts.js.map +1 -0
- package/dist/vkui/vk-view.d.ts +26 -0
- package/dist/vkui/vk-view.d.ts.map +1 -0
- package/dist/vkui/vk-view.js +71 -0
- package/dist/vkui/vk-view.js.map +1 -0
- package/dist/vkui.d.ts +5 -0
- package/dist/vkui.js +5 -0
- package/package.json +70 -3
- package/src/core/bridge-like.ts +25 -0
- package/src/core/bridge-sync.ts +95 -0
- package/src/core/hash-params.ts +73 -0
- package/src/core/loose-route.ts +17 -0
- package/src/core/panels-stack.ts +85 -0
- package/src/core/state-keys.ts +39 -0
- package/src/core/swipe-back.ts +80 -0
- package/src/core/vk-history.ts +146 -0
- package/src/index.ts +30 -0
- package/src/preset/app.tsx +158 -0
- package/src/preset/builders.tsx +566 -0
- package/src/preset/define-panel.ts +109 -0
- package/src/preset/define-view.ts +73 -0
- package/src/preset/inflate.ts +57 -0
- package/src/preset/navigator.ts +252 -0
- package/src/preset.ts +51 -0
- package/src/react/hooks.ts +168 -0
- package/src/react.ts +11 -0
- package/src/vkui/choreography.ts +105 -0
- package/src/vkui/modals.tsx +216 -0
- package/src/vkui/not-found.tsx +21 -0
- package/src/vkui/popouts.tsx +106 -0
- package/src/vkui/vk-view.tsx +136 -0
- package/src/vkui.ts +17 -0
- package/README.md +0 -3
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hash payload helpers for the VK history.
|
|
3
|
+
*
|
|
4
|
+
* Two URL modes:
|
|
5
|
+
* - `hash` (default): the hash IS the location (`#/item/7?tab=x`),
|
|
6
|
+
* TanStack-style — parity with the old `createHashRouter`.
|
|
7
|
+
* - `hash-param`: the whole router location rides in ONE hash query param
|
|
8
|
+
* (`#?path=%2Fitem%2F7`), foreign hash params (launch params, UTM) are
|
|
9
|
+
* preserved next to it and never leak into the router's search. The real
|
|
10
|
+
* `location.search` — where VK puts launch params on the web platform —
|
|
11
|
+
* is never touched. Parity with the old `createHashParamRouter`.
|
|
12
|
+
*
|
|
13
|
+
* Both read paths tolerate LEGACY deep links written by the other mode
|
|
14
|
+
* (`#/path` resolves in hash-param mode, `#?path=…` resolves in hash
|
|
15
|
+
* mode); writes always use the active mode's canonical format.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export function readHashParams(win: Window): URLSearchParams {
|
|
19
|
+
return new URLSearchParams(win.location.hash.replace(/^#\?/, ""));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Router href from the document hash, tolerant to both modes:
|
|
24
|
+
* `#?path=%2Flist&x=1` → `/list` (hash-param), `#/list?x=1` → `/list?x=1`
|
|
25
|
+
* (hash), empty or foreign hash → `/`.
|
|
26
|
+
*/
|
|
27
|
+
export function readHrefFromHash(win: Window, pathParam: string): string {
|
|
28
|
+
const hash = win.location.hash;
|
|
29
|
+
if (hash.startsWith("#?")) {
|
|
30
|
+
return new URLSearchParams(hash.slice(2)).get(pathParam) ?? "/";
|
|
31
|
+
}
|
|
32
|
+
if (hash.startsWith("#/")) {
|
|
33
|
+
return hash.slice(1);
|
|
34
|
+
}
|
|
35
|
+
return "/";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Serialize a router href into the hash, preserving foreign params. */
|
|
39
|
+
export function writeHashParams(win: Window, pathParam: string, href: string): string {
|
|
40
|
+
const params = readHashParams(win);
|
|
41
|
+
params.set(pathParam, href);
|
|
42
|
+
return `${win.location.pathname}${win.location.search}#?${params.toString()}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Hash content (`?…` string) for a router href — the SetLocation payload. */
|
|
46
|
+
export function serializeHashPayload(win: Window, pathParam: string, href: string): string {
|
|
47
|
+
const params = readHashParams(win);
|
|
48
|
+
params.set(pathParam, href);
|
|
49
|
+
return `?${params.toString()}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Extract the router href from a SetLocation/ChangeFragment payload. */
|
|
53
|
+
export function parseHashPayload(payload: string, pathParam: string): string {
|
|
54
|
+
if (payload.startsWith("/")) {
|
|
55
|
+
// legacy plain-hash payload (old createHashRouter hosts)
|
|
56
|
+
return payload;
|
|
57
|
+
}
|
|
58
|
+
return new URLSearchParams(payload.replace(/^\?/, "")).get(pathParam) ?? "/";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Hash-mode SetLocation payload: the hash content IS the location. */
|
|
62
|
+
export function serializeHashLocation(location: {
|
|
63
|
+
pathname: string;
|
|
64
|
+
search: string;
|
|
65
|
+
hash: string;
|
|
66
|
+
}): string {
|
|
67
|
+
return `${location.pathname}${location.search}${location.hash}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Hash-mode payload → router href (normalized to a leading slash). */
|
|
71
|
+
export function parseHashLocation(payload: string): string {
|
|
72
|
+
return payload.startsWith("/") ? payload : `/${payload}`;
|
|
73
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loosely-typed view of a registered route. `AnyRoute`'s options bag is
|
|
3
|
+
* generic-erased (`any`), so every consumer of `router.routesById` needs
|
|
4
|
+
* the same structural recast — this owns it once.
|
|
5
|
+
*/
|
|
6
|
+
export interface LooseRoute {
|
|
7
|
+
options: { staticData?: { panel?: string; modal?: string } };
|
|
8
|
+
fullPath?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Read a route out of the router's registered tree without the cast noise. */
|
|
12
|
+
export function looseRouteOf(
|
|
13
|
+
router: { routesById: unknown },
|
|
14
|
+
routeId: string,
|
|
15
|
+
): LooseRoute | undefined {
|
|
16
|
+
return (router.routesById as Record<string, LooseRoute>)[routeId];
|
|
17
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mirror of the browser history stack in terms of VKUI panel ids, indexed
|
|
3
|
+
* by `location.state.__TSR_index` (the absolute position in the history
|
|
4
|
+
* stack). Replaces the old router's key-matching ViewHistory with direct
|
|
5
|
+
* indexing. Pure — the React binding gates writes on the router's
|
|
6
|
+
* `status === "idle"` (the location commit lands a render before match
|
|
7
|
+
* resolution, so a naive write records the stale panel at the new index).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface PanelsStackState {
|
|
11
|
+
/** panel id per history index (null = unknown entry) */
|
|
12
|
+
entries: Array<string | null>;
|
|
13
|
+
/** current history index */
|
|
14
|
+
cursor: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const initialPanelsStack = (): PanelsStackState => ({
|
|
18
|
+
entries: [],
|
|
19
|
+
cursor: -1,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param index `location.state.__TSR_index`, or null when the history entry
|
|
24
|
+
* carries no TanStack state (manual hash edit / foreign entry).
|
|
25
|
+
*/
|
|
26
|
+
export function applyLocation(
|
|
27
|
+
prev: PanelsStackState,
|
|
28
|
+
index: number | null,
|
|
29
|
+
panel: string,
|
|
30
|
+
): PanelsStackState {
|
|
31
|
+
const at = index ?? prev.cursor + 1;
|
|
32
|
+
|
|
33
|
+
if (prev.cursor === -1) {
|
|
34
|
+
// init (cold start / reload / deep link): anchor at the real history
|
|
35
|
+
// index — `__TSR_index` survives reloads in history.state. Earlier
|
|
36
|
+
// slots are unknown (null) and skipped by panelsHistoryOf.
|
|
37
|
+
const filled: Array<string | null> = [];
|
|
38
|
+
for (let i = 0; i <= at; i++) {
|
|
39
|
+
filled[i] = i === at ? panel : null;
|
|
40
|
+
}
|
|
41
|
+
return { entries: filled, cursor: at };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (at > prev.cursor) {
|
|
45
|
+
// push: truncate the forward branch, then place the panel at `at`
|
|
46
|
+
const truncated = prev.entries.slice(0, prev.cursor + 1);
|
|
47
|
+
const filled: Array<string | null> = [];
|
|
48
|
+
for (let i = 0; i <= at; i++) {
|
|
49
|
+
filled[i] = i === at ? panel : (truncated[i] ?? null);
|
|
50
|
+
}
|
|
51
|
+
return { entries: filled, cursor: at };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (at === prev.cursor) {
|
|
55
|
+
// replace (same slot, new location)
|
|
56
|
+
const entries = [...prev.entries];
|
|
57
|
+
entries[at] = panel;
|
|
58
|
+
return { entries, cursor: at };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// pop (back/forward into a known or unknown earlier slot)
|
|
62
|
+
const filled: Array<string | null> = [];
|
|
63
|
+
for (let i = 0; i <= at; i++) {
|
|
64
|
+
filled[i] = prev.entries[i] ?? null;
|
|
65
|
+
}
|
|
66
|
+
if (filled[at] === null) {
|
|
67
|
+
filled[at] = panel;
|
|
68
|
+
}
|
|
69
|
+
return { entries: filled, cursor: at };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* VKUI `View.history` prop: panels in open order. Overlay entries never
|
|
74
|
+
* reach this stack (they live in history.state, not as panels).
|
|
75
|
+
*/
|
|
76
|
+
export function panelsHistoryOf(state: PanelsStackState): string[] {
|
|
77
|
+
const out: string[] = [];
|
|
78
|
+
for (let i = 0; i <= state.cursor; i++) {
|
|
79
|
+
const panel = state.entries[i];
|
|
80
|
+
if (panel) {
|
|
81
|
+
out.push(panel);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** The key the popout overlay rides under in `history.state`. */
|
|
2
|
+
export const POPOUT_STATE_KEY = "vkma:popout";
|
|
3
|
+
|
|
4
|
+
export interface PopoutState {
|
|
5
|
+
id: string;
|
|
6
|
+
props?: Record<string, unknown>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** The key runtime modal props ride under in `history.state`. */
|
|
10
|
+
export const MODAL_STATE_KEY = "vkma:modal";
|
|
11
|
+
|
|
12
|
+
export interface ModalState {
|
|
13
|
+
/** Serializable props passed via `showModal({ props })`. */
|
|
14
|
+
props?: unknown;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function readModalState(state: unknown): ModalState | undefined {
|
|
18
|
+
if (state !== null && typeof state === "object" && MODAL_STATE_KEY in state) {
|
|
19
|
+
const modal = (state as Record<string, unknown>)[MODAL_STATE_KEY];
|
|
20
|
+
if (modal !== null && typeof modal === "object") {
|
|
21
|
+
return modal;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function readPopoutState(state: unknown): PopoutState | undefined {
|
|
28
|
+
if (state !== null && typeof state === "object" && POPOUT_STATE_KEY in state) {
|
|
29
|
+
const popout = (state as Record<string, unknown>)[POPOUT_STATE_KEY];
|
|
30
|
+
if (
|
|
31
|
+
popout !== null &&
|
|
32
|
+
typeof popout === "object" &&
|
|
33
|
+
typeof (popout as PopoutState).id === "string"
|
|
34
|
+
) {
|
|
35
|
+
return popout as PopoutState;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { VkBridgeLike } from "./bridge-like.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Auto swipe-back management: the native gesture
|
|
5
|
+
* (`VKWebAppEnableSwipeBack` / `VKWebAppSetSwipeSettings { history }`) is
|
|
6
|
+
* enabled while the app can go back in its own history and disabled on the
|
|
7
|
+
* first entry — where the system back must close the app. Unsupported
|
|
8
|
+
* platforms (web) answer with error code 6; that is silenced, like the old
|
|
9
|
+
* router did.
|
|
10
|
+
*
|
|
11
|
+
* Two manual layers on top of the automatic decision:
|
|
12
|
+
* - `enabled: false` is a hard opt-out — the gesture is never enabled
|
|
13
|
+
* (an app that drives the gesture itself, or none at all);
|
|
14
|
+
* - `setOverride(boolean)` — fed by `useEnableSwipeBack` — replaces the
|
|
15
|
+
* automatic decision while set; `null` returns to it.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** The one active manager — `useEnableSwipeBack` talks to the last created
|
|
19
|
+
* one (a mini app has exactly one history). `destroy` unregisters. */
|
|
20
|
+
let activeManager: { setOverride(enabled: boolean | null): void } | null = null;
|
|
21
|
+
|
|
22
|
+
/** Manual override slot behind `useEnableSwipeBack`. */
|
|
23
|
+
export function setSwipeBackOverride(enabled: boolean | null): void {
|
|
24
|
+
activeManager?.setOverride(enabled);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface SwipeBackManagerOptions {
|
|
28
|
+
bridge: VkBridgeLike;
|
|
29
|
+
history: {
|
|
30
|
+
location: { state?: { __TSR_index?: number } };
|
|
31
|
+
subscribe(listener: () => void): () => void;
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Hard opt-out: the native gesture is never enabled, whatever the
|
|
35
|
+
* history position or an override says. Default `true`.
|
|
36
|
+
*/
|
|
37
|
+
enabled?: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function createSwipeBackManager(options: SwipeBackManagerOptions): {
|
|
41
|
+
destroy: () => void;
|
|
42
|
+
/** Manual override — `null` returns to the automatic decision. */
|
|
43
|
+
setOverride: (enabled: boolean | null) => void;
|
|
44
|
+
} {
|
|
45
|
+
let lastEnabled: boolean | null = null;
|
|
46
|
+
let override: boolean | null = null;
|
|
47
|
+
|
|
48
|
+
const sync = (): void => {
|
|
49
|
+
const index = options.history.location.state?.__TSR_index;
|
|
50
|
+
const auto = index === undefined ? false : index > 0;
|
|
51
|
+
const enabled = options.enabled === false ? false : (override ?? auto);
|
|
52
|
+
if (enabled === lastEnabled) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
lastEnabled = enabled;
|
|
56
|
+
void options.bridge.send("VKWebAppSetSwipeSettings", { history: enabled }).catch(() => {});
|
|
57
|
+
void options.bridge
|
|
58
|
+
.send(enabled ? "VKWebAppEnableSwipeBack" : "VKWebAppDisableSwipeBack")
|
|
59
|
+
.catch(() => {});
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
sync();
|
|
63
|
+
const unsubscribe = options.history.subscribe(sync);
|
|
64
|
+
const manager = {
|
|
65
|
+
setOverride: (enabled: boolean | null): void => {
|
|
66
|
+
override = enabled;
|
|
67
|
+
sync();
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
activeManager = manager;
|
|
71
|
+
return {
|
|
72
|
+
setOverride: manager.setOverride,
|
|
73
|
+
destroy: () => {
|
|
74
|
+
unsubscribe();
|
|
75
|
+
if (activeManager === manager) {
|
|
76
|
+
activeManager = null;
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { createBrowserHistory } from "@tanstack/history";
|
|
2
|
+
import {
|
|
3
|
+
parseHashLocation,
|
|
4
|
+
parseHashPayload,
|
|
5
|
+
readHrefFromHash,
|
|
6
|
+
serializeHashPayload,
|
|
7
|
+
writeHashParams,
|
|
8
|
+
} from "./hash-params.ts";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* VK history: the same trick TanStack's own hash history uses —
|
|
12
|
+
* `createBrowserHistory` with swapped `parseLocation`/`createHref` — so we
|
|
13
|
+
* inherit blockers with rollback, popstate classification, throttling and
|
|
14
|
+
* TSR index/key stamping.
|
|
15
|
+
*
|
|
16
|
+
* Modes (parity with the old router's three factories):
|
|
17
|
+
* - `"hash"` (default) → old `createHashRouter`: the hash IS the location
|
|
18
|
+
* (`#/item/7?tab=x`).
|
|
19
|
+
* - `"hash-param"` → old `createHashParamRouter`: the whole router
|
|
20
|
+
* location rides in one hash query param (`#?path=%2Fitem%2F7`), foreign
|
|
21
|
+
* hash params (launch params, UTM) are preserved next to it, and the
|
|
22
|
+
* real `location.search` is never touched.
|
|
23
|
+
*
|
|
24
|
+
* Deep links written by the OTHER mode resolve too (legacy tolerance).
|
|
25
|
+
* `"browser"` → pass TanStack's default history instead (omit `history`
|
|
26
|
+
* in createRouter) — old `createBrowserRouter`.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
export const DEFAULT_PATH_PARAM = "path";
|
|
30
|
+
|
|
31
|
+
export type VkHistoryMode = "hash" | "hash-param";
|
|
32
|
+
|
|
33
|
+
interface HistoryState {
|
|
34
|
+
key: string;
|
|
35
|
+
__TSR_key: string;
|
|
36
|
+
__TSR_index: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface VkHistoryOptions {
|
|
40
|
+
window?: Window;
|
|
41
|
+
/** Hash query param carrying the router location (hash-param mode). */
|
|
42
|
+
pathParam?: string;
|
|
43
|
+
/** URL mode. Default `"hash"`. */
|
|
44
|
+
mode?: VkHistoryMode;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The history plus the payload codecs the bridge sync layer needs. */
|
|
48
|
+
export function createVkHistory(options: VkHistoryOptions = {}) {
|
|
49
|
+
const win = options.window ?? window;
|
|
50
|
+
const pathParam = options.pathParam ?? DEFAULT_PATH_PARAM;
|
|
51
|
+
const mode = options.mode ?? "hash";
|
|
52
|
+
|
|
53
|
+
let lastIndex = -1;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A history entry without TanStack state (manual hash edit / foreign
|
|
57
|
+
* pushState) gets a FRESH index = lastIndex + 1, so popstate classifies
|
|
58
|
+
* it as FORWARD — the entry behaves like a push (an entry parsed as
|
|
59
|
+
* index 0 would desync any parallel stack mirror).
|
|
60
|
+
*/
|
|
61
|
+
const stampMissingState = (): HistoryState => {
|
|
62
|
+
lastIndex += 1;
|
|
63
|
+
const key = Math.random().toString(36).slice(2, 8);
|
|
64
|
+
const state: HistoryState = { __TSR_index: lastIndex, key, __TSR_key: key };
|
|
65
|
+
win.history.replaceState(state, "", win.location.href);
|
|
66
|
+
return state;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const initStamp = (): HistoryState => {
|
|
70
|
+
const rawState = win.history.state as HistoryState | null;
|
|
71
|
+
if (rawState?.__TSR_key) {
|
|
72
|
+
if (rawState.__TSR_index > lastIndex) {
|
|
73
|
+
lastIndex = rawState.__TSR_index;
|
|
74
|
+
}
|
|
75
|
+
return rawState;
|
|
76
|
+
}
|
|
77
|
+
return stampMissingState();
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
if (mode === "hash-param") {
|
|
81
|
+
const parseLocation = () => {
|
|
82
|
+
const state = initStamp();
|
|
83
|
+
const href = readHrefFromHash(win, pathParam);
|
|
84
|
+
const url = new URL(href, win.location.origin);
|
|
85
|
+
return {
|
|
86
|
+
href,
|
|
87
|
+
pathname: url.pathname,
|
|
88
|
+
search: url.search,
|
|
89
|
+
hash: url.hash,
|
|
90
|
+
state,
|
|
91
|
+
};
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
history: createBrowserHistory({
|
|
96
|
+
window: win,
|
|
97
|
+
parseLocation,
|
|
98
|
+
createHref: href => writeHashParams(win, pathParam, href),
|
|
99
|
+
}),
|
|
100
|
+
/** Hash content for a router href — the SetLocation payload. */
|
|
101
|
+
serializePayload: (href: string) => serializeHashPayload(win, pathParam, href),
|
|
102
|
+
/** Router href from a SetLocation/ChangeFragment payload. */
|
|
103
|
+
parsePayload: (payload: string) => parseHashPayload(payload, pathParam),
|
|
104
|
+
/** Router href from the current document hash. */
|
|
105
|
+
parseCurrentHash: () => readHrefFromHash(win, pathParam),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// hash mode: the hash IS the location; legacy `#?path=` links resolve too
|
|
110
|
+
const hrefFromHash = (): string => {
|
|
111
|
+
const hash = win.location.hash;
|
|
112
|
+
if (hash.startsWith("#?")) {
|
|
113
|
+
// legacy hash-param deep link — resolve the path param out of it
|
|
114
|
+
return new URLSearchParams(hash.slice(2)).get(pathParam) ?? "/";
|
|
115
|
+
}
|
|
116
|
+
return hash.length > 1 ? hash.slice(1) : "/";
|
|
117
|
+
};
|
|
118
|
+
const parseLocation = () => {
|
|
119
|
+
const state = initStamp();
|
|
120
|
+
const href = hrefFromHash();
|
|
121
|
+
const url = new URL(href, win.location.origin);
|
|
122
|
+
return {
|
|
123
|
+
href,
|
|
124
|
+
pathname: url.pathname,
|
|
125
|
+
search: url.search,
|
|
126
|
+
hash: url.hash,
|
|
127
|
+
state,
|
|
128
|
+
};
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
history: createBrowserHistory({
|
|
133
|
+
window: win,
|
|
134
|
+
parseLocation,
|
|
135
|
+
createHref: href => `${win.location.pathname}${win.location.search}#${href}`,
|
|
136
|
+
}),
|
|
137
|
+
/** Hash content for a router href — the SetLocation payload. */
|
|
138
|
+
serializePayload: (href: string) => href,
|
|
139
|
+
/** Router href from a SetLocation/ChangeFragment payload. */
|
|
140
|
+
parsePayload: (payload: string) => parseHashLocation(payload),
|
|
141
|
+
/** Router href from the current document hash. */
|
|
142
|
+
parseCurrentHash: hrefFromHash,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export type VkHistory = ReturnType<typeof createVkHistory>;
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export {
|
|
2
|
+
DEFAULT_PATH_PARAM,
|
|
3
|
+
createVkHistory,
|
|
4
|
+
type VkHistory,
|
|
5
|
+
type VkHistoryMode,
|
|
6
|
+
type VkHistoryOptions,
|
|
7
|
+
} from "./core/vk-history.ts";
|
|
8
|
+
export {
|
|
9
|
+
applyLocation,
|
|
10
|
+
initialPanelsStack,
|
|
11
|
+
panelsHistoryOf,
|
|
12
|
+
type PanelsStackState,
|
|
13
|
+
} from "./core/panels-stack.ts";
|
|
14
|
+
export { POPOUT_STATE_KEY, readPopoutState, type PopoutState } from "./core/state-keys.ts";
|
|
15
|
+
export { createBridgeSync, type BridgeSyncOptions } from "./core/bridge-sync.ts";
|
|
16
|
+
export { createSwipeBackManager } from "./core/swipe-back.ts";
|
|
17
|
+
export {
|
|
18
|
+
normalizeBridgeEvent,
|
|
19
|
+
type BridgeEventEnvelope,
|
|
20
|
+
type VkBridgeLike,
|
|
21
|
+
} from "./core/bridge-like.ts";
|
|
22
|
+
export {
|
|
23
|
+
readHashParams,
|
|
24
|
+
readHrefFromHash,
|
|
25
|
+
writeHashParams,
|
|
26
|
+
serializeHashPayload,
|
|
27
|
+
serializeHashLocation,
|
|
28
|
+
parseHashLocation,
|
|
29
|
+
parseHashPayload,
|
|
30
|
+
} from "./core/hash-params.ts";
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { useRouter, useRouterState } from "@tanstack/react-router";
|
|
2
|
+
import { useEffect, useRef } from "react";
|
|
3
|
+
import { looseRouteOf } from "../core/loose-route.ts";
|
|
4
|
+
import { Modals } from "../vkui/modals.tsx";
|
|
5
|
+
import { NotFound } from "../vkui/not-found.tsx";
|
|
6
|
+
import { Popouts } from "../vkui/popouts.tsx";
|
|
7
|
+
import { VkView } from "../vkui/vk-view.tsx";
|
|
8
|
+
import type { AppHandle } from "./builders.tsx";
|
|
9
|
+
import { deriveInflateChain, interpolatePath } from "./inflate.ts";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Default root shell: the active view + the modal/popout hosts + inflate.
|
|
13
|
+
* The active view is the one containing the deepest matched route
|
|
14
|
+
* (single-view apps always get their only view). Multi-story apps with
|
|
15
|
+
* Epic/Root are built with a custom `root` shell out of the same pieces.
|
|
16
|
+
*
|
|
17
|
+
* An unmatched URL (global 404 or a `notFound()` thrown by `beforeLoad`)
|
|
18
|
+
* renders the VKUI `NotFound` instead of a view — falling back to the
|
|
19
|
+
* first view here would show its lazy panel as a spinner that never
|
|
20
|
+
* resolves, because an unmatched panel's chunk is never loaded.
|
|
21
|
+
*/
|
|
22
|
+
export function AppRootShell({ app, inflate = true }: { app: AppHandle; inflate?: boolean }) {
|
|
23
|
+
useInflate(app, inflate);
|
|
24
|
+
|
|
25
|
+
const activeViewId = useRouterState({
|
|
26
|
+
select: state => {
|
|
27
|
+
const notFound = state.matches.some(
|
|
28
|
+
match =>
|
|
29
|
+
(match as { status?: string }).status === "notFound" ||
|
|
30
|
+
(match as { _notFound?: boolean })._notFound === true,
|
|
31
|
+
);
|
|
32
|
+
if (notFound) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
const views = Object.values(app.views);
|
|
36
|
+
for (let i = state.matches.length - 1; i >= 0; i--) {
|
|
37
|
+
const routeId = state.matches[i]?.routeId ?? "";
|
|
38
|
+
const view = views.find(
|
|
39
|
+
view => routeId === view.id || routeId.startsWith(`${view.id}/`),
|
|
40
|
+
);
|
|
41
|
+
if (view !== undefined) {
|
|
42
|
+
return view.id;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
const activeView =
|
|
49
|
+
activeViewId === null
|
|
50
|
+
? undefined
|
|
51
|
+
: Object.values(app.views).find(view => view.id === activeViewId);
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<>
|
|
55
|
+
{activeView !== undefined ? <VkView view={activeView} /> : <NotFound />}
|
|
56
|
+
<Modals entries={app.modals} />
|
|
57
|
+
<Popouts entries={app.popouts} />
|
|
58
|
+
</>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Inflate (spike parity): a cold start (first history entry) on a panel
|
|
64
|
+
* that is NOT the first of its view replays the panel flow before it, so
|
|
65
|
+
* back walks the natural chain instead of closing the app. One-shot, waits
|
|
66
|
+
* for the router to go idle, skips warm navigations. `AppRootShell` runs
|
|
67
|
+
* it automatically; custom shells (Epic/Tabbar) call the hook themselves.
|
|
68
|
+
*/
|
|
69
|
+
export function useInflate(app: AppHandle, enabled = true): void {
|
|
70
|
+
const router = useRouter();
|
|
71
|
+
const snapshot = useRouterState({
|
|
72
|
+
select: state => {
|
|
73
|
+
let panelKey: string | null = null;
|
|
74
|
+
let panelPath: string | null = null;
|
|
75
|
+
const modalPaths: string[] = [];
|
|
76
|
+
for (let i = state.matches.length - 1; i >= 0; i--) {
|
|
77
|
+
const routeId = state.matches[i]?.routeId;
|
|
78
|
+
if (routeId === undefined) {
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const route = looseRouteOf(router, routeId);
|
|
82
|
+
const staticData = route?.options.staticData;
|
|
83
|
+
if (staticData?.modal !== undefined && route !== undefined) {
|
|
84
|
+
// modal levels of the deep link, deepest first (reversed below)
|
|
85
|
+
if (route.fullPath !== undefined) {
|
|
86
|
+
modalPaths.push(route.fullPath);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (staticData?.panel !== undefined) {
|
|
90
|
+
panelKey = staticData.panel;
|
|
91
|
+
panelPath = route?.fullPath ?? null;
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
status: state.status,
|
|
97
|
+
index:
|
|
98
|
+
(state.location.state as { __TSR_index?: number } | undefined)?.__TSR_index ??
|
|
99
|
+
0,
|
|
100
|
+
href: state.location.href,
|
|
101
|
+
panelKey,
|
|
102
|
+
panelPath,
|
|
103
|
+
modalPaths: modalPaths.toReversed(),
|
|
104
|
+
deepestParams:
|
|
105
|
+
(
|
|
106
|
+
state.matches[state.matches.length - 1] as
|
|
107
|
+
| { params?: Record<string, string> }
|
|
108
|
+
| undefined
|
|
109
|
+
)?.params ?? {},
|
|
110
|
+
};
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
const done = useRef(false);
|
|
114
|
+
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
if (done.current || !enabled || snapshot.status !== "idle") {
|
|
117
|
+
return; // one-shot; retry until the router settles
|
|
118
|
+
}
|
|
119
|
+
done.current = true;
|
|
120
|
+
if (snapshot.index > 0 || snapshot.panelKey === null) {
|
|
121
|
+
return; // warm navigation or an unknown panel — nothing to inflate
|
|
122
|
+
}
|
|
123
|
+
const target = app.panels[snapshot.panelKey];
|
|
124
|
+
if (target === undefined) {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const ordered = Object.values(app.panels).filter(panel => panel.viewId === target.viewId);
|
|
128
|
+
let chain = deriveInflateChain(ordered, target.routeId, snapshot.deepestParams);
|
|
129
|
+
// a deep link INTO A ROUTE MODAL: the full ancestor chain must sit in
|
|
130
|
+
// history below the target — the parent panel, then every modal level
|
|
131
|
+
// (outer → inner) — or back skips the screens the user is logically
|
|
132
|
+
// on. Only modal targets: for a panel target the parentHref IS the
|
|
133
|
+
// current location and would replay a pointless no-op navigation
|
|
134
|
+
if (snapshot.modalPaths.length > 0 && snapshot.panelPath !== null) {
|
|
135
|
+
const parentHref = interpolatePath(snapshot.panelPath, snapshot.deepestParams);
|
|
136
|
+
if (parentHref !== null && chain[chain.length - 1] !== parentHref) {
|
|
137
|
+
chain = [...chain, parentHref];
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
for (const modalPath of snapshot.modalPaths) {
|
|
141
|
+
const modalHref = interpolatePath(modalPath, snapshot.deepestParams);
|
|
142
|
+
if (modalHref !== null && chain[chain.length - 1] !== modalHref) {
|
|
143
|
+
chain = [...chain, modalHref];
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (chain.length === 0) {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const { href: targetHref } = snapshot;
|
|
150
|
+
void (async () => {
|
|
151
|
+
await router.navigate({ href: chain[0], replace: true });
|
|
152
|
+
for (let i = 1; i < chain.length; i++) {
|
|
153
|
+
await router.navigate({ href: chain[i] });
|
|
154
|
+
}
|
|
155
|
+
await router.navigate({ href: targetHref });
|
|
156
|
+
})();
|
|
157
|
+
});
|
|
158
|
+
}
|