@vkma.dev/router 0.0.0-stage → 0.1.1

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 (111) hide show
  1. package/LICENSE +21 -0
  2. package/dist/core/bridge-like.d.ts +25 -0
  3. package/dist/core/bridge-like.d.ts.map +1 -0
  4. package/dist/core/bridge-like.js +13 -0
  5. package/dist/core/bridge-like.js.map +1 -0
  6. package/dist/core/bridge-sync.d.ts +37 -0
  7. package/dist/core/bridge-sync.d.ts.map +1 -0
  8. package/dist/core/bridge-sync.js +58 -0
  9. package/dist/core/bridge-sync.js.map +1 -0
  10. package/dist/core/hash-params.d.ts +40 -0
  11. package/dist/core/hash-params.d.ts.map +1 -0
  12. package/dist/core/hash-params.js +60 -0
  13. package/dist/core/hash-params.js.map +1 -0
  14. package/dist/core/loose-route.js +9 -0
  15. package/dist/core/loose-route.js.map +1 -0
  16. package/dist/core/panels-stack.d.ts +28 -0
  17. package/dist/core/panels-stack.d.ts.map +1 -0
  18. package/dist/core/panels-stack.js +60 -0
  19. package/dist/core/panels-stack.js.map +1 -0
  20. package/dist/core/state-keys.d.ts +10 -0
  21. package/dist/core/state-keys.d.ts.map +1 -0
  22. package/dist/core/state-keys.js +21 -0
  23. package/dist/core/state-keys.js.map +1 -0
  24. package/dist/core/swipe-back.d.ts +25 -0
  25. package/dist/core/swipe-back.d.ts.map +1 -0
  26. package/dist/core/swipe-back.js +53 -0
  27. package/dist/core/swipe-back.js.map +1 -0
  28. package/dist/core/vk-history.d.ts +41 -0
  29. package/dist/core/vk-history.d.ts.map +1 -0
  30. package/dist/core/vk-history.js +115 -0
  31. package/dist/core/vk-history.js.map +1 -0
  32. package/dist/index.d.ts +8 -0
  33. package/dist/index.js +8 -0
  34. package/dist/preset/app.d.ts +35 -0
  35. package/dist/preset/app.d.ts.map +1 -0
  36. package/dist/preset/app.js +134 -0
  37. package/dist/preset/app.js.map +1 -0
  38. package/dist/preset/builders.d.ts +209 -0
  39. package/dist/preset/builders.d.ts.map +1 -0
  40. package/dist/preset/builders.js +209 -0
  41. package/dist/preset/builders.js.map +1 -0
  42. package/dist/preset/define-panel.d.ts +77 -0
  43. package/dist/preset/define-panel.d.ts.map +1 -0
  44. package/dist/preset/define-panel.js +42 -0
  45. package/dist/preset/define-panel.js.map +1 -0
  46. package/dist/preset/define-view.d.ts +42 -0
  47. package/dist/preset/define-view.d.ts.map +1 -0
  48. package/dist/preset/define-view.js +43 -0
  49. package/dist/preset/define-view.js.map +1 -0
  50. package/dist/preset/inflate.d.ts +17 -0
  51. package/dist/preset/inflate.d.ts.map +1 -0
  52. package/dist/preset/inflate.js +40 -0
  53. package/dist/preset/inflate.js.map +1 -0
  54. package/dist/preset/navigator.d.ts +75 -0
  55. package/dist/preset/navigator.d.ts.map +1 -0
  56. package/dist/preset/navigator.js +116 -0
  57. package/dist/preset/navigator.js.map +1 -0
  58. package/dist/preset.d.ts +17 -0
  59. package/dist/preset.js +16 -0
  60. package/dist/react/hooks.d.ts +69 -0
  61. package/dist/react/hooks.d.ts.map +1 -0
  62. package/dist/react/hooks.js +101 -0
  63. package/dist/react/hooks.js.map +1 -0
  64. package/dist/react.d.ts +2 -0
  65. package/dist/react.js +1 -0
  66. package/dist/vkui/choreography.js +59 -0
  67. package/dist/vkui/choreography.js.map +1 -0
  68. package/dist/vkui/modals.d.ts +49 -0
  69. package/dist/vkui/modals.d.ts.map +1 -0
  70. package/dist/vkui/modals.js +116 -0
  71. package/dist/vkui/modals.js.map +1 -0
  72. package/dist/vkui/not-found.d.ts +9 -0
  73. package/dist/vkui/not-found.d.ts.map +1 -0
  74. package/dist/vkui/not-found.js +23 -0
  75. package/dist/vkui/not-found.js.map +1 -0
  76. package/dist/vkui/popouts.d.ts +34 -0
  77. package/dist/vkui/popouts.d.ts.map +1 -0
  78. package/dist/vkui/popouts.js +69 -0
  79. package/dist/vkui/popouts.js.map +1 -0
  80. package/dist/vkui/vk-view.d.ts +26 -0
  81. package/dist/vkui/vk-view.d.ts.map +1 -0
  82. package/dist/vkui/vk-view.js +81 -0
  83. package/dist/vkui/vk-view.js.map +1 -0
  84. package/dist/vkui.d.ts +5 -0
  85. package/dist/vkui.js +5 -0
  86. package/package.json +70 -3
  87. package/src/core/bridge-like.ts +25 -0
  88. package/src/core/bridge-sync.ts +95 -0
  89. package/src/core/hash-params.ts +73 -0
  90. package/src/core/loose-route.ts +17 -0
  91. package/src/core/panels-stack.ts +85 -0
  92. package/src/core/state-keys.ts +39 -0
  93. package/src/core/swipe-back.ts +80 -0
  94. package/src/core/vk-history.ts +146 -0
  95. package/src/index.ts +30 -0
  96. package/src/preset/app.tsx +192 -0
  97. package/src/preset/builders.tsx +566 -0
  98. package/src/preset/define-panel.ts +109 -0
  99. package/src/preset/define-view.ts +73 -0
  100. package/src/preset/inflate.ts +57 -0
  101. package/src/preset/navigator.ts +250 -0
  102. package/src/preset.ts +51 -0
  103. package/src/react/hooks.ts +168 -0
  104. package/src/react.ts +11 -0
  105. package/src/vkui/choreography.ts +105 -0
  106. package/src/vkui/modals.tsx +216 -0
  107. package/src/vkui/not-found.tsx +21 -0
  108. package/src/vkui/popouts.tsx +106 -0
  109. package/src/vkui/vk-view.tsx +147 -0
  110. package/src/vkui.ts +17 -0
  111. 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,192 @@
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 { storyKeyOf, VkView } from "../vkui/vk-view.tsx";
8
+ import type { AppHandle } from "./builders.tsx";
9
+ import { deriveInflateChain, interpolatePath } from "./inflate.ts";
10
+
11
+ interface MatchLike {
12
+ routeId?: string;
13
+ status?: string;
14
+ _notFound?: boolean;
15
+ }
16
+
17
+ /**
18
+ * The view route owning the deepest matched route, or null. The walk starts
19
+ * at the deepest match because the chain depth varies — a modal is a masked
20
+ * CHILD route of its panel, so `/view:feed/feed/comments` must still resolve
21
+ * to the feed view. A notFound match anywhere in the chain wins over
22
+ * everything: the shell must show the NotFound screen, never a story
23
+ * fallback (an unmatched lazy panel's chunk never loads — a fallback story
24
+ * would spin forever).
25
+ */
26
+ export function owningViewIdOf(
27
+ matches: readonly MatchLike[],
28
+ views: readonly { id: string }[],
29
+ ): string | null {
30
+ const notFound = matches.some(
31
+ match =>
32
+ match.status === "notFound" || (match as { _notFound?: boolean })._notFound === true,
33
+ );
34
+ if (notFound) {
35
+ return null;
36
+ }
37
+ for (let i = matches.length - 1; i >= 0; i--) {
38
+ const routeId = matches[i]?.routeId ?? "";
39
+ const view = views.find(view => routeId === view.id || routeId.startsWith(`${view.id}/`));
40
+ if (view !== undefined) {
41
+ return view.id;
42
+ }
43
+ }
44
+ return null;
45
+ }
46
+
47
+ /**
48
+ * Default root shell: the active view + the modal/popout hosts + inflate.
49
+ * The active view is the one containing the deepest matched route
50
+ * (single-view apps always get their only view). Multi-story apps with
51
+ * Epic/Root are built with a custom `root` shell out of the same pieces.
52
+ *
53
+ * An unmatched URL (global 404 or a `notFound()` thrown by `beforeLoad`)
54
+ * renders the VKUI `NotFound` instead of a view — falling back to the
55
+ * first view here would show its lazy panel as a spinner that never
56
+ * resolves, because an unmatched panel's chunk is never loaded.
57
+ */
58
+ export function AppRootShell({ app, inflate = true }: { app: AppHandle; inflate?: boolean }) {
59
+ useInflate(app, inflate);
60
+ const views = Object.values(app.views);
61
+
62
+ const activeViewId = useRouterState({
63
+ select: (state): string | null => owningViewIdOf(state.matches, views),
64
+ });
65
+ const activeView =
66
+ activeViewId === null
67
+ ? undefined
68
+ : Object.values(app.views).find(view => view.id === activeViewId);
69
+
70
+ return (
71
+ <>
72
+ {activeView !== undefined ? <VkView view={activeView} /> : <NotFound />}
73
+ <Modals entries={app.modals} />
74
+ <Popouts entries={app.popouts} />
75
+ </>
76
+ );
77
+ }
78
+
79
+ /**
80
+ * The active story key for Epic/Root shells: the view key the app author
81
+ * wrote in `defineView("feed")`, or null on a notFound navigation — render
82
+ * the NotFound screen instead of a story. Pairs with `<VkView view={…}>`
83
+ * whose nav defaults to the same key, so `activeStory === tab.id` compares
84
+ * user-authored keys on both sides.
85
+ */
86
+ export function useActiveStory(app: AppHandle): string | null {
87
+ const views = Object.values(app.views);
88
+ return useRouterState({
89
+ select: (state): string | null => {
90
+ const viewId = owningViewIdOf(state.matches, views);
91
+ return viewId === null ? null : storyKeyOf(viewId);
92
+ },
93
+ });
94
+ }
95
+
96
+ /**
97
+ * Inflate (spike parity): a cold start (first history entry) on a panel
98
+ * that is NOT the first of its view replays the panel flow before it, so
99
+ * back walks the natural chain instead of closing the app. One-shot, waits
100
+ * for the router to go idle, skips warm navigations. `AppRootShell` runs
101
+ * it automatically; custom shells (Epic/Tabbar) call the hook themselves.
102
+ */
103
+ export function useInflate(app: AppHandle, enabled = true): void {
104
+ const router = useRouter();
105
+ const snapshot = useRouterState({
106
+ select: state => {
107
+ let panelKey: string | null = null;
108
+ let panelPath: string | null = null;
109
+ const modalPaths: string[] = [];
110
+ for (let i = state.matches.length - 1; i >= 0; i--) {
111
+ const routeId = state.matches[i]?.routeId;
112
+ if (routeId === undefined) {
113
+ continue;
114
+ }
115
+ const route = looseRouteOf(router, routeId);
116
+ const staticData = route?.options.staticData;
117
+ if (staticData?.modal !== undefined && route !== undefined) {
118
+ // modal levels of the deep link, deepest first (reversed below)
119
+ if (route.fullPath !== undefined) {
120
+ modalPaths.push(route.fullPath);
121
+ }
122
+ }
123
+ if (staticData?.panel !== undefined) {
124
+ panelKey = staticData.panel;
125
+ panelPath = route?.fullPath ?? null;
126
+ break;
127
+ }
128
+ }
129
+ return {
130
+ status: state.status,
131
+ index:
132
+ (state.location.state as { __TSR_index?: number } | undefined)?.__TSR_index ??
133
+ 0,
134
+ href: state.location.href,
135
+ panelKey,
136
+ panelPath,
137
+ modalPaths: modalPaths.toReversed(),
138
+ deepestParams:
139
+ (
140
+ state.matches[state.matches.length - 1] as
141
+ | { params?: Record<string, string> }
142
+ | undefined
143
+ )?.params ?? {},
144
+ };
145
+ },
146
+ });
147
+ const done = useRef(false);
148
+
149
+ useEffect(() => {
150
+ if (done.current || !enabled || snapshot.status !== "idle") {
151
+ return; // one-shot; retry until the router settles
152
+ }
153
+ done.current = true;
154
+ if (snapshot.index > 0 || snapshot.panelKey === null) {
155
+ return; // warm navigation or an unknown panel — nothing to inflate
156
+ }
157
+ const target = app.panels[snapshot.panelKey];
158
+ if (target === undefined) {
159
+ return;
160
+ }
161
+ const ordered = Object.values(app.panels).filter(panel => panel.viewId === target.viewId);
162
+ let chain = deriveInflateChain(ordered, target.routeId, snapshot.deepestParams);
163
+ // a deep link INTO A ROUTE MODAL: the full ancestor chain must sit in
164
+ // history below the target — the parent panel, then every modal level
165
+ // (outer → inner) — or back skips the screens the user is logically
166
+ // on. Only modal targets: for a panel target the parentHref IS the
167
+ // current location and would replay a pointless no-op navigation
168
+ if (snapshot.modalPaths.length > 0 && snapshot.panelPath !== null) {
169
+ const parentHref = interpolatePath(snapshot.panelPath, snapshot.deepestParams);
170
+ if (parentHref !== null && chain[chain.length - 1] !== parentHref) {
171
+ chain = [...chain, parentHref];
172
+ }
173
+ }
174
+ for (const modalPath of snapshot.modalPaths) {
175
+ const modalHref = interpolatePath(modalPath, snapshot.deepestParams);
176
+ if (modalHref !== null && chain[chain.length - 1] !== modalHref) {
177
+ chain = [...chain, modalHref];
178
+ }
179
+ }
180
+ if (chain.length === 0) {
181
+ return;
182
+ }
183
+ const { href: targetHref } = snapshot;
184
+ void (async () => {
185
+ await router.navigate({ href: chain[0], replace: true });
186
+ for (let i = 1; i < chain.length; i++) {
187
+ await router.navigate({ href: chain[i] });
188
+ }
189
+ await router.navigate({ href: targetHref });
190
+ })();
191
+ });
192
+ }