@vkma.dev/router 0.1.3 → 0.1.4

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/dist/preset.d.ts CHANGED
@@ -11,8 +11,8 @@ import { AppRootShell, useActiveStory, useInflate } from "./preset/app.js";
11
11
  import { PanelPage, VkNavigator, useNavigator } from "./preset/navigator.js";
12
12
  import { VkLinkProps, createVkLink } from "./preset/link.js";
13
13
  import { deriveInflateChain } from "./preset/inflate.js";
14
- import { VkView } from "./vkui/vk-view.js";
14
+ import { DefaultPanelPendingComponent, VkView } from "./vkui/vk-view.js";
15
15
  import { NotFound } from "./vkui/not-found.js";
16
16
  import "./vkui.js";
17
17
  import { PanelRef, useEnableSwipeBack, useModalProps, usePanelLoaderData, usePanelParams, usePanelSearch, usePanelsHistory } from "./react/hooks.js";
18
- export { type AppHandle, type AppOptions, AppRootShell, ModalCardSurface, type ModalComponent, type ModalDeclaration, type ModalEntry, type ModalEntryComponent, ModalPageSurface, Modals, NotFound, type PanelDefinition, type PanelHandle, type PanelHandlesOf, type PanelOptions, type PanelPage, type PanelRef, Popouts, type TypedAppHandle, type ViewDefinition, type ViewOptions, type VkBridgeLike, type VkLinkProps, type VkNavigator, VkView, createApp, createBridgeSync, createSwipeBackManager, createVkHistory, createVkLink, definePanel, defineView, deriveInflateChain, normalizeBridgeEvent, panel, panelRouteId, useActiveStory, useEnableSwipeBack, useHidePopout, useInflate, useModalProps, useNavigator, usePanelLoaderData, usePanelParams, usePanelSearch, usePanelsHistory, usePopoutState, useShowPopout };
18
+ export { type AppHandle, type AppOptions, AppRootShell, DefaultPanelPendingComponent, ModalCardSurface, type ModalComponent, type ModalDeclaration, type ModalEntry, type ModalEntryComponent, ModalPageSurface, Modals, NotFound, type PanelDefinition, type PanelHandle, type PanelHandlesOf, type PanelOptions, type PanelPage, type PanelRef, Popouts, type TypedAppHandle, type ViewDefinition, type ViewOptions, type VkBridgeLike, type VkLinkProps, type VkNavigator, VkView, createApp, createBridgeSync, createSwipeBackManager, createVkHistory, createVkLink, definePanel, defineView, deriveInflateChain, normalizeBridgeEvent, panel, panelRouteId, useActiveStory, useEnableSwipeBack, useHidePopout, useInflate, useModalProps, useNavigator, usePanelLoaderData, usePanelParams, usePanelSearch, usePanelsHistory, usePopoutState, useShowPopout };
package/dist/preset.js CHANGED
@@ -3,7 +3,7 @@ import { normalizeBridgeEvent } from "./core/bridge-like.js";
3
3
  import { createBridgeSync } from "./core/bridge-sync.js";
4
4
  import { createSwipeBackManager } from "./core/swipe-back.js";
5
5
  import { useEnableSwipeBack, useModalProps, usePanelLoaderData, usePanelParams, usePanelSearch, usePanelsHistory } from "./react/hooks.js";
6
- import { VkView } from "./vkui/vk-view.js";
6
+ import { DefaultPanelPendingComponent, VkView } from "./vkui/vk-view.js";
7
7
  import { ModalCardSurface, ModalPageSurface, Modals } from "./vkui/modals.js";
8
8
  import { Popouts, useHidePopout, usePopoutState, useShowPopout } from "./vkui/popouts.js";
9
9
  import { NotFound } from "./vkui/not-found.js";
@@ -14,4 +14,4 @@ import { defineView } from "./preset/define-view.js";
14
14
  import { createApp, panel } from "./preset/builders.js";
15
15
  import { createVkLink } from "./preset/link.js";
16
16
  import { useNavigator } from "./preset/navigator.js";
17
- export { AppRootShell, ModalCardSurface, ModalPageSurface, Modals, NotFound, Popouts, VkView, createApp, createBridgeSync, createSwipeBackManager, createVkHistory, createVkLink, definePanel, defineView, deriveInflateChain, normalizeBridgeEvent, panel, panelRouteId, useActiveStory, useEnableSwipeBack, useHidePopout, useInflate, useModalProps, useNavigator, usePanelLoaderData, usePanelParams, usePanelSearch, usePanelsHistory, usePopoutState, useShowPopout };
17
+ export { AppRootShell, DefaultPanelPendingComponent, ModalCardSurface, ModalPageSurface, Modals, NotFound, Popouts, VkView, createApp, createBridgeSync, createSwipeBackManager, createVkHistory, createVkLink, definePanel, defineView, deriveInflateChain, normalizeBridgeEvent, panel, panelRouteId, useActiveStory, useEnableSwipeBack, useHidePopout, useInflate, useModalProps, useNavigator, usePanelLoaderData, usePanelParams, usePanelSearch, usePanelsHistory, usePopoutState, useShowPopout };
@@ -31,5 +31,15 @@ export interface VkViewProps {
31
31
  * `pendingComponent` renders instead of the component.
32
32
  */
33
33
  export declare function VkView({ view, nav, onSwipeBack }: VkViewProps): import("react").JSX.Element;
34
+ /**
35
+ * The canonical pending screen for a panel: a header stub keeps the layout
36
+ * structure while the lazy chunk is in flight, and PanelSpinner centers
37
+ * itself inside the panel (a bare Spinner floats at the top of a void).
38
+ *
39
+ * `VkView` renders this whenever a chunk is genuinely absent past the grace
40
+ * delay (see the pending branches in the render above); pass it as a
41
+ * panel's `pendingComponent` to opt out of the grace delay instead.
42
+ */
43
+ export declare function DefaultPanelPendingComponent(): import("react").JSX.Element;
34
44
  //#endregion
35
45
  //# sourceMappingURL=vk-view.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"vk-view.d.ts","names":[],"sources":["../../src/vkui/vk-view.tsx"],"mappings":";;wBAUgB,UAAU,OAAO;;;;;;;wBA+BjB,WAAW;iBAKV;;EAEhB,MAAM;;EAEN;;EAEA;;;;;;;;;;;;;;;;wBAiBe,SAAS,MAAM,KAAK,eAAe,8BAAW,IAAA"}
1
+ {"version":3,"file":"vk-view.d.ts","names":[],"sources":["../../src/vkui/vk-view.tsx"],"mappings":";;wBAUgB,UAAU,OAAO;;;;;;;wBA+BjB,WAAW;iBAKV;;EAEhB,MAAM;;EAEN;;EAEA;;;;;;;;;;;;;;;;wBAiBe,SAAS,MAAM,KAAK,eAAe,8BAAW,IAAA;;;;;;;;;;wBA8G9C,gDAA4B,IAAA"}
@@ -66,8 +66,8 @@ function VkView({ view, nav, onSwipeBack }) {
66
66
  if (isNotFound) content = null;
67
67
  else if (Component !== void 0) content = /* @__PURE__ */ jsx(Component, {});
68
68
  else if (isPending && Pending !== void 0) content = /* @__PURE__ */ jsx(Pending, {});
69
- else if (isPending) content = /* @__PURE__ */ jsx(PanelSpinnerFallback, {});
70
- else content = null;
69
+ else if (isPending) content = /* @__PURE__ */ jsx(DefaultPanelPendingComponent, {});
70
+ else content = /* @__PURE__ */ jsx(PanelHeader, {});
71
71
  return /* @__PURE__ */ jsx(Panel, {
72
72
  nav: id,
73
73
  children: content
@@ -75,7 +75,16 @@ function VkView({ view, nav, onSwipeBack }) {
75
75
  })
76
76
  });
77
77
  }
78
- function PanelSpinnerFallback() {
78
+ /**
79
+ * The canonical pending screen for a panel: a header stub keeps the layout
80
+ * structure while the lazy chunk is in flight, and PanelSpinner centers
81
+ * itself inside the panel (a bare Spinner floats at the top of a void).
82
+ *
83
+ * `VkView` renders this whenever a chunk is genuinely absent past the grace
84
+ * delay (see the pending branches in the render above); pass it as a
85
+ * panel's `pendingComponent` to opt out of the grace delay instead.
86
+ */
87
+ function DefaultPanelPendingComponent() {
79
88
  return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(PanelHeader, {}), /* @__PURE__ */ jsx(PanelSpinner, { size: "l" })] });
80
89
  }
81
90
  /**
@@ -111,6 +120,6 @@ function useStalledIds(pendingIds, delayMs) {
111
120
  return stalledKey === key && key !== "" ? pendingIds : [];
112
121
  }
113
122
  //#endregion
114
- export { VkView, panelIdOf, storyKeyOf };
123
+ export { DefaultPanelPendingComponent, VkView, panelIdOf, storyKeyOf };
115
124
 
116
125
  //# sourceMappingURL=vk-view.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"vk-view.js","names":[],"sources":["../../src/vkui/vk-view.tsx"],"sourcesContent":["import type { AnyRoute } from \"@tanstack/react-router\";\nimport { useRouterState } from \"@tanstack/react-router\";\nimport { Panel, PanelHeader, PanelSpinner, View } from \"@vkontakte/vkui\";\nimport { useEffect, useState, type ComponentType, type ReactNode } from \"react\";\nimport { usePanelsHistory } from \"../react/hooks.ts\";\n\nexport interface PanelStaticData {\n\tpanel: string;\n}\n\nexport function panelIdOf(route: AnyRoute): string {\n\treturn (route.options.staticData as PanelStaticData | undefined)?.panel ?? \"\";\n}\n\nfunction deepestPanelMatchId(view: AnyRoute, matchedRouteIds: string[]): string {\n\tconst panelIds = new Set(view.children.map((child: AnyRoute) => child.id));\n\tfor (let i = matchedRouteIds.length - 1; i >= 0; i--) {\n\t\tconst id = matchedRouteIds[i];\n\t\tif (id !== undefined && panelIds.has(id)) {\n\t\t\treturn id;\n\t\t}\n\t}\n\treturn view.children[0]?.id ?? \"\";\n}\n\nfunction useMatchSummaries(): string {\n\treturn useRouterState({\n\t\tselect: state =>\n\t\t\t// routeId + status: a lazy panel's chunk resolution flips the match\n\t\t\t// status, which must re-render the static children (the resolved\n\t\t\t// component merges into route.options at that point)\n\t\t\tstate.matches.map(match => `${match.routeId}#${match.status}`).join(\"|\"),\n\t});\n}\n\n/**\n * The VKUI nav key of a view route: \"/view:feed\" → \"feed\". Epic/Root match\n * children by getNavId, so the raw route id would leak the route-tree\n * convention into the UI layer. This is also the key the app author wrote\n * in `defineView(\"feed\")`.\n */\nexport function storyKeyOf(routeId: unknown): string {\n\tconst id = String(routeId);\n\treturn id.split(\":\")[1] ?? id;\n}\n\nexport interface VkViewProps {\n\t/** The pathless view layout route created by the preset. */\n\tview: AnyRoute;\n\t/** VKUI nav id of the View. Defaults to the story key (the defineView key). */\n\tnav?: string;\n\t/** Native swipe-back pop; defaults to `window.history.back()`. */\n\tonSwipeBack?: () => void;\n}\n\n/**\n * Static-children view: every panel route renders as an always-present JSX\n * child of VKUI <View>; the router only feeds `activePanel` (and `history`\n * for the iOS swipe-back). VKUI hides inactive panels itself and infers\n * the animation direction from the children order — a plain <Outlet>\n * breaks both. A lazy panel's `options.component` is undefined until its\n * chunk resolves (TanStack merges loaded options in place), so the panel\n * renders a spinner fallback until then — but only after a short grace\n * delay: a fast chunk load must not flash a spinner for a frame or two,\n * the panel just fills in seamlessly. For the same reason TanStack's\n * Outlet-driven `pendingComponent` swap never happens on its own — while\n * the panel's match is pending (loader/beforeLoad), the route's\n * `pendingComponent` renders instead of the component.\n */\nexport function VkView({ view, nav, onSwipeBack }: VkViewProps) {\n\tuseMatchSummaries();\n\tconst matchedRouteIds = useRouterState({\n\t\tselect: state => state.matches.map(match => match.routeId),\n\t});\n\t// structurally-compared array — TanStack keeps the reference stable when\n\t// the selection is deep-equal. TanStack's Outlet would swap in the\n\t// route's pendingComponent itself, but static children render the\n\t// component directly, so the swap is ours to make while the loader runs\n\tconst pendingRouteIds = useRouterState({\n\t\tselect: state =>\n\t\t\tstate.matches\n\t\t\t\t.filter(match => (match as { status?: string }).status === \"pending\")\n\t\t\t\t.map(match => String(match.routeId)),\n\t});\n\t// The grace delay: a pending match only earns the spinner fallback after\n\t// staying pending for this long. Fast lazy-chunk loads (a warm cache, a\n\t// small bundle) resolve within a frame or two — flashing a spinner for\n\t// that blink makes every navigation feel broken; showing nothing and\n\t// letting the panel fill in seamlessly looks instant. Real loaders\n\t// outlive the delay and still get the fallback.\n\tconst stalledRouteIds = useStalledIds(pendingRouteIds, 200);\n\t// a notFound match (notFound() from beforeLoad/validateSearch) must NOT\n\t// render its panel component: the component's data guards assume a valid\n\t// match, and the shell renders the NotFound screen for this navigation —\n\t// the panel itself stays invisible. Without the gate the component would\n\t// throw in a transient render and dirty the console on every cold 404\n\tconst notFoundRouteIds = useRouterState({\n\t\tselect: state =>\n\t\t\tstate.matches\n\t\t\t\t.filter(match => (match as { status?: string }).status === \"notFound\")\n\t\t\t\t.map(match => String(match.routeId)),\n\t});\n\tconst activeRouteId = deepestPanelMatchId(view, matchedRouteIds);\n\tconst activeChild =\n\t\tview.children.find((child: AnyRoute) => child.id === activeRouteId) ?? view.children[0];\n\tconst activePanel = activeChild ? panelIdOf(activeChild) : \"\";\n\n\tconst history = usePanelsHistory(activePanel);\n\n\treturn (\n\t\t<View\n\t\t\tnav={nav ?? storyKeyOf(view.id)}\n\t\t\tactivePanel={activePanel}\n\t\t\thistory={history}\n\t\t\tonSwipeBack={onSwipeBack ?? (() => window.history.back())}\n\t\t>\n\t\t\t{view.children.map((child: AnyRoute) => {\n\t\t\t\tconst id = panelIdOf(child);\n\t\t\t\tconst Component = child.options.component as ComponentType | undefined;\n\t\t\t\tconst Pending = child.options.pendingComponent as ComponentType | undefined;\n\t\t\t\t// the grace-gated pending set (useStalledIds): a fast chunk load\n\t\t\t\t// never shows a spinner, the panel just fills in seamlessly\n\t\t\t\tconst isPending = stalledRouteIds.includes(String(child.id));\n\t\t\t\tconst isNotFound = notFoundRouteIds.includes(String(child.id));\n\t\t\t\t// A LOADED COMPONENT ALWAYS WINS — even while its match is still\n\t\t\t\t// pending. The fallback may only appear when the component is\n\t\t\t\t// genuinely absent (chunk not merged yet): replacing rendered\n\t\t\t\t// content with a spinner after it already appeared reads as\n\t\t\t\t// broken. (Panels reading route data through the /react hooks\n\t\t\t\t// are safe to render in a transient pending render; raw\n\t\t\t\t// TanStack data hooks are not — see the AGENTS invariants.)\n\t\t\t\tlet content: ReactNode;\n\t\t\t\tif (isNotFound) {\n\t\t\t\t\t// parked on a notFound boundary up the tree — the component's\n\t\t\t\t\t// data guards assume a resolvable match and would throw in a\n\t\t\t\t\t// hidden panel; the shell renders the NotFound screen anyway\n\t\t\t\t\tcontent = null;\n\t\t\t\t} else if (Component !== undefined) {\n\t\t\t\t\t// A LOADED COMPONENT ALWAYS WINS — even while its match is\n\t\t\t\t\t// still pending. The fallback may only appear when the\n\t\t\t\t\t// component is genuinely absent (chunk not merged yet):\n\t\t\t\t\t// replacing rendered content with a spinner after it already\n\t\t\t\t\t// appeared reads as broken. (Panels reading route data\n\t\t\t\t\t// through the /react hooks are safe to render in a transient\n\t\t\t\t\t// pending render; raw TanStack data hooks are not — see the\n\t\t\t\t\t// AGENTS invariants.)\n\t\t\t\t\tcontent = <Component />;\n\t\t\t\t} else if (isPending && Pending !== undefined) {\n\t\t\t\t\t// an explicit pendingComponent is deliberate — no grace delay\n\t\t\t\t\tcontent = <Pending />;\n\t\t\t\t} else if (isPending) {\n\t\t\t\t\tcontent = <PanelSpinnerFallback />;\n\t\t\t\t} else {\n\t\t\t\t\t// chunk in flight, inside the grace window: an empty panel\n\t\t\t\t\t// beats a spinner that would flash for a blink\n\t\t\t\t\tcontent = null;\n\t\t\t\t}\n\t\t\t\treturn (\n\t\t\t\t\t<Panel key={id} nav={id}>\n\t\t\t\t\t\t{content}\n\t\t\t\t\t</Panel>\n\t\t\t\t);\n\t\t\t})}\n\t\t</View>\n\t);\n}\n\nfunction PanelSpinnerFallback() {\n\t// The canonical VKUI loading screen: a header stub keeps the layout\n\t// structure while the lazy chunk is in flight, and PanelSpinner centers\n\t// itself inside the panel (a bare Spinner floats at the top of a void).\n\treturn (\n\t\t<>\n\t\t\t<PanelHeader />\n\t\t\t<PanelSpinner size=\"l\" />\n\t\t</>\n\t);\n}\n\n/**\n * The delayed-spinner gate: mirrors `pendingIds` but only after they have\n * stayed pending for `delayMs` continuously; clears the moment the pending\n * set empties. `pendingRouteIds` comes from a structurally-stable\n * `useRouterState` select — the reference only changes when the pending set\n * actually changes, so the timer legitimately resets per navigation.\n */\n/**\n * The delayed-spinner gate: mirrors `pendingIds` but only after they have\n * stayed pending for `delayMs` continuously; clears the moment the pending\n * set empties. The state is a STRING KEY, not the array — array-typed state\n * set from an effect re-triggers renders (a fresh reference every time)\n * and loops; a key string with the React bail-out (same value → no\n * re-render) cannot. `key` is stable across re-renders as long as the\n * pending SET is the same, even if TanStack hands out a fresh array\n * reference per render.\n */\nfunction useStalledIds(pendingIds: string[], delayMs: number): string[] {\n\tconst key = pendingIds.join(\"|\");\n\tconst [stalledKey, setStalledKey] = useState(\"\");\n\tuseEffect(() => {\n\t\tif (key === \"\") {\n\t\t\tsetStalledKey(prev => (prev === \"\" ? prev : \"\"));\n\t\t\treturn;\n\t\t}\n\t\tconst timer = setTimeout(() => setStalledKey(key), delayMs);\n\t\treturn () => {\n\t\t\tclearTimeout(timer);\n\t\t};\n\t}, [key, delayMs]);\n\treturn stalledKey === key && key !== \"\" ? pendingIds : [];\n}\n"],"mappings":";;;;;;AAUA,SAAgB,UAAU,OAAyB;CAClD,OAAQ,MAAM,QAAQ,YAA4C,SAAS;AAC5E;AAEA,SAAS,oBAAoB,MAAgB,iBAAmC;CAC/E,MAAM,WAAW,IAAI,IAAI,KAAK,SAAS,KAAK,UAAoB,MAAM,EAAE,CAAC;CACzE,KAAK,IAAI,IAAI,gBAAgB,SAAS,GAAG,KAAK,GAAG,KAAK;EACrD,MAAM,KAAK,gBAAgB;EAC3B,IAAI,OAAO,KAAA,KAAa,SAAS,IAAI,EAAE,GACtC,OAAO;CAET;CACA,OAAO,KAAK,SAAS,EAAE,EAAE,MAAM;AAChC;AAEA,SAAS,oBAA4B;CACpC,OAAO,eAAe,EACrB,SAAQ,UAIP,MAAM,QAAQ,KAAI,UAAS,GAAG,MAAM,QAAQ,GAAG,MAAM,QAAQ,CAAC,CAAC,KAAK,GAAG,EACzE,CAAC;AACF;;;;;;;AAQA,SAAgB,WAAW,SAA0B;CACpD,MAAM,KAAK,OAAO,OAAO;CACzB,OAAO,GAAG,MAAM,GAAG,CAAC,CAAC,MAAM;AAC5B;;;;;;;;;;;;;;;AAyBA,SAAgB,OAAO,EAAE,MAAM,KAAK,eAA4B;CAC/D,kBAAkB;CAClB,MAAM,kBAAkB,eAAe,EACtC,SAAQ,UAAS,MAAM,QAAQ,KAAI,UAAS,MAAM,OAAO,EAC1D,CAAC;CAiBD,MAAM,kBAAkB,cAZA,eAAe,EACtC,SAAQ,UACP,MAAM,QACJ,QAAO,UAAU,MAA8B,WAAW,SAAS,CAAC,CACpE,KAAI,UAAS,OAAO,MAAM,OAAO,CAAC,EACtC,CAOoD,GAAG,GAAG;CAM1D,MAAM,mBAAmB,eAAe,EACvC,SAAQ,UACP,MAAM,QACJ,QAAO,UAAU,MAA8B,WAAW,UAAU,CAAC,CACrE,KAAI,UAAS,OAAO,MAAM,OAAO,CAAC,EACtC,CAAC;CACD,MAAM,gBAAgB,oBAAoB,MAAM,eAAe;CAC/D,MAAM,cACL,KAAK,SAAS,MAAM,UAAoB,MAAM,OAAO,aAAa,KAAK,KAAK,SAAS;CACtF,MAAM,cAAc,cAAc,UAAU,WAAW,IAAI;CAE3D,MAAM,UAAU,iBAAiB,WAAW;CAE5C,OACC,oBAAC,MAAD;EACC,KAAK,OAAO,WAAW,KAAK,EAAE;EACjB;EACJ;EACT,aAAa,sBAAsB,OAAO,QAAQ,KAAK;EAEtD,UAAA,KAAK,SAAS,KAAK,UAAoB;GACvC,MAAM,KAAK,UAAU,KAAK;GAC1B,MAAM,YAAY,MAAM,QAAQ;GAChC,MAAM,UAAU,MAAM,QAAQ;GAG9B,MAAM,YAAY,gBAAgB,SAAS,OAAO,MAAM,EAAE,CAAC;GAC3D,MAAM,aAAa,iBAAiB,SAAS,OAAO,MAAM,EAAE,CAAC;GAQ7D,IAAI;GACJ,IAAI,YAIH,UAAU;QACJ,IAAI,cAAc,KAAA,GASxB,UAAU,oBAAC,WAAD,CAAY,CAAA;QAChB,IAAI,aAAa,YAAY,KAAA,GAEnC,UAAU,oBAAC,SAAD,CAAU,CAAA;QACd,IAAI,WACV,UAAU,oBAAC,sBAAD,CAAuB,CAAA;QAIjC,UAAU;GAEX,OACC,oBAAC,OAAD;IAAgB,KAAK;IACnB,UAAA;GACK,GAFK,EAEL;EAET,CAAC;CACI,CAAA;AAER;AAEA,SAAS,uBAAuB;CAI/B,OACC,qBAAA,UAAA,EAAA,UAAA,CACC,oBAAC,aAAD,CAAc,CAAA,GACd,oBAAC,cAAD,EAAc,MAAK,IAAK,CAAA,CACvB,EAAA,CAAA;AAEJ;;;;;;;;;;;;;;;;;;AAmBA,SAAS,cAAc,YAAsB,SAA2B;CACvE,MAAM,MAAM,WAAW,KAAK,GAAG;CAC/B,MAAM,CAAC,YAAY,iBAAiB,SAAS,EAAE;CAC/C,gBAAgB;EACf,IAAI,QAAQ,IAAI;GACf,eAAc,SAAS,SAAS,KAAK,OAAO,EAAG;GAC/C;EACD;EACA,MAAM,QAAQ,iBAAiB,cAAc,GAAG,GAAG,OAAO;EAC1D,aAAa;GACZ,aAAa,KAAK;EACnB;CACD,GAAG,CAAC,KAAK,OAAO,CAAC;CACjB,OAAO,eAAe,OAAO,QAAQ,KAAK,aAAa,CAAC;AACzD"}
1
+ {"version":3,"file":"vk-view.js","names":[],"sources":["../../src/vkui/vk-view.tsx"],"sourcesContent":["import type { AnyRoute } from \"@tanstack/react-router\";\nimport { useRouterState } from \"@tanstack/react-router\";\nimport { Panel, PanelHeader, PanelSpinner, View } from \"@vkontakte/vkui\";\nimport { useEffect, useState, type ComponentType, type ReactNode } from \"react\";\nimport { usePanelsHistory } from \"../react/hooks.ts\";\n\nexport interface PanelStaticData {\n\tpanel: string;\n}\n\nexport function panelIdOf(route: AnyRoute): string {\n\treturn (route.options.staticData as PanelStaticData | undefined)?.panel ?? \"\";\n}\n\nfunction deepestPanelMatchId(view: AnyRoute, matchedRouteIds: string[]): string {\n\tconst panelIds = new Set(view.children.map((child: AnyRoute) => child.id));\n\tfor (let i = matchedRouteIds.length - 1; i >= 0; i--) {\n\t\tconst id = matchedRouteIds[i];\n\t\tif (id !== undefined && panelIds.has(id)) {\n\t\t\treturn id;\n\t\t}\n\t}\n\treturn view.children[0]?.id ?? \"\";\n}\n\nfunction useMatchSummaries(): string {\n\treturn useRouterState({\n\t\tselect: state =>\n\t\t\t// routeId + status: a lazy panel's chunk resolution flips the match\n\t\t\t// status, which must re-render the static children (the resolved\n\t\t\t// component merges into route.options at that point)\n\t\t\tstate.matches.map(match => `${match.routeId}#${match.status}`).join(\"|\"),\n\t});\n}\n\n/**\n * The VKUI nav key of a view route: \"/view:feed\" → \"feed\". Epic/Root match\n * children by getNavId, so the raw route id would leak the route-tree\n * convention into the UI layer. This is also the key the app author wrote\n * in `defineView(\"feed\")`.\n */\nexport function storyKeyOf(routeId: unknown): string {\n\tconst id = String(routeId);\n\treturn id.split(\":\")[1] ?? id;\n}\n\nexport interface VkViewProps {\n\t/** The pathless view layout route created by the preset. */\n\tview: AnyRoute;\n\t/** VKUI nav id of the View. Defaults to the story key (the defineView key). */\n\tnav?: string;\n\t/** Native swipe-back pop; defaults to `window.history.back()`. */\n\tonSwipeBack?: () => void;\n}\n\n/**\n * Static-children view: every panel route renders as an always-present JSX\n * child of VKUI <View>; the router only feeds `activePanel` (and `history`\n * for the iOS swipe-back). VKUI hides inactive panels itself and infers\n * the animation direction from the children order — a plain <Outlet>\n * breaks both. A lazy panel's `options.component` is undefined until its\n * chunk resolves (TanStack merges loaded options in place), so the panel\n * renders a spinner fallback until then — but only after a short grace\n * delay: a fast chunk load must not flash a spinner for a frame or two,\n * the panel just fills in seamlessly. For the same reason TanStack's\n * Outlet-driven `pendingComponent` swap never happens on its own — while\n * the panel's match is pending (loader/beforeLoad), the route's\n * `pendingComponent` renders instead of the component.\n */\nexport function VkView({ view, nav, onSwipeBack }: VkViewProps) {\n\tuseMatchSummaries();\n\tconst matchedRouteIds = useRouterState({\n\t\tselect: state => state.matches.map(match => match.routeId),\n\t});\n\t// structurally-compared array — TanStack keeps the reference stable when\n\t// the selection is deep-equal. TanStack's Outlet would swap in the\n\t// route's pendingComponent itself, but static children render the\n\t// component directly, so the swap is ours to make while the loader runs\n\tconst pendingRouteIds = useRouterState({\n\t\tselect: state =>\n\t\t\tstate.matches\n\t\t\t\t.filter(match => (match as { status?: string }).status === \"pending\")\n\t\t\t\t.map(match => String(match.routeId)),\n\t});\n\t// The grace delay: a pending match only earns the spinner fallback after\n\t// staying pending for this long. Fast lazy-chunk loads (a warm cache, a\n\t// small bundle) resolve within a frame or two — flashing a spinner for\n\t// that blink makes every navigation feel broken; showing nothing and\n\t// letting the panel fill in seamlessly looks instant. Real loaders\n\t// outlive the delay and still get the fallback.\n\tconst stalledRouteIds = useStalledIds(pendingRouteIds, 200);\n\t// a notFound match (notFound() from beforeLoad/validateSearch) must NOT\n\t// render its panel component: the component's data guards assume a valid\n\t// match, and the shell renders the NotFound screen for this navigation —\n\t// the panel itself stays invisible. Without the gate the component would\n\t// throw in a transient render and dirty the console on every cold 404\n\tconst notFoundRouteIds = useRouterState({\n\t\tselect: state =>\n\t\t\tstate.matches\n\t\t\t\t.filter(match => (match as { status?: string }).status === \"notFound\")\n\t\t\t\t.map(match => String(match.routeId)),\n\t});\n\tconst activeRouteId = deepestPanelMatchId(view, matchedRouteIds);\n\tconst activeChild =\n\t\tview.children.find((child: AnyRoute) => child.id === activeRouteId) ?? view.children[0];\n\tconst activePanel = activeChild ? panelIdOf(activeChild) : \"\";\n\n\tconst history = usePanelsHistory(activePanel);\n\n\treturn (\n\t\t<View\n\t\t\tnav={nav ?? storyKeyOf(view.id)}\n\t\t\tactivePanel={activePanel}\n\t\t\thistory={history}\n\t\t\tonSwipeBack={onSwipeBack ?? (() => window.history.back())}\n\t\t>\n\t\t\t{view.children.map((child: AnyRoute) => {\n\t\t\t\tconst id = panelIdOf(child);\n\t\t\t\tconst Component = child.options.component as ComponentType | undefined;\n\t\t\t\tconst Pending = child.options.pendingComponent as ComponentType | undefined;\n\t\t\t\t// the grace-gated pending set (useStalledIds): a fast chunk load\n\t\t\t\t// never shows a spinner, the panel just fills in seamlessly\n\t\t\t\tconst isPending = stalledRouteIds.includes(String(child.id));\n\t\t\t\tconst isNotFound = notFoundRouteIds.includes(String(child.id));\n\t\t\t\t// A LOADED COMPONENT ALWAYS WINS — even while its match is still\n\t\t\t\t// pending. The fallback may only appear when the component is\n\t\t\t\t// genuinely absent (chunk not merged yet): replacing rendered\n\t\t\t\t// content with a spinner after it already appeared reads as\n\t\t\t\t// broken. (Panels reading route data through the /react hooks\n\t\t\t\t// are safe to render in a transient pending render; raw\n\t\t\t\t// TanStack data hooks are not — see the AGENTS invariants.)\n\t\t\t\tlet content: ReactNode;\n\t\t\t\tif (isNotFound) {\n\t\t\t\t\t// parked on a notFound boundary up the tree — the component's\n\t\t\t\t\t// data guards assume a resolvable match and would throw in a\n\t\t\t\t\t// hidden panel; the shell renders the NotFound screen anyway\n\t\t\t\t\tcontent = null;\n\t\t\t\t} else if (Component !== undefined) {\n\t\t\t\t\t// A LOADED COMPONENT ALWAYS WINS — even while its match is\n\t\t\t\t\t// still pending. The fallback may only appear when the\n\t\t\t\t\t// component is genuinely absent (chunk not merged yet):\n\t\t\t\t\t// replacing rendered content with a spinner after it already\n\t\t\t\t\t// appeared reads as broken. (Panels reading route data\n\t\t\t\t\t// through the /react hooks are safe to render in a transient\n\t\t\t\t\t// pending render; raw TanStack data hooks are not — see the\n\t\t\t\t\t// AGENTS invariants.)\n\t\t\t\t\tcontent = <Component />;\n\t\t\t\t} else if (isPending && Pending !== undefined) {\n\t\t\t\t\t// an explicit pendingComponent is deliberate — no grace delay\n\t\t\t\t\tcontent = <Pending />;\n\t\t\t\t} else if (isPending) {\n\t\t\t\t\tcontent = <DefaultPanelPendingComponent />;\n\t\t\t\t} else {\n\t\t\t\t\t// chunk in flight, inside the grace window: a header stub beats\n\t\t\t\t\t// a spinner that would flash for a blink — and beats an empty\n\t\t\t\t\t// panel: every panel SEPARATES with its own <PanelHeader>, so\n\t\t\t\t\t// the stub paints the final layout slot and the arriving\n\t\t\t\t\t// content fills in under it, nothing jumps\n\t\t\t\t\tcontent = <PanelHeader />;\n\t\t\t\t}\n\t\t\t\treturn (\n\t\t\t\t\t<Panel key={id} nav={id}>\n\t\t\t\t\t\t{content}\n\t\t\t\t\t</Panel>\n\t\t\t\t);\n\t\t\t})}\n\t\t</View>\n\t);\n}\n\n/**\n * The canonical pending screen for a panel: a header stub keeps the layout\n * structure while the lazy chunk is in flight, and PanelSpinner centers\n * itself inside the panel (a bare Spinner floats at the top of a void).\n *\n * `VkView` renders this whenever a chunk is genuinely absent past the grace\n * delay (see the pending branches in the render above); pass it as a\n * panel's `pendingComponent` to opt out of the grace delay instead.\n */\nexport function DefaultPanelPendingComponent() {\n\treturn (\n\t\t<>\n\t\t\t<PanelHeader />\n\t\t\t<PanelSpinner size=\"l\" />\n\t\t</>\n\t);\n}\n\n/**\n * The delayed-spinner gate: mirrors `pendingIds` but only after they have\n * stayed pending for `delayMs` continuously; clears the moment the pending\n * set empties. `pendingRouteIds` comes from a structurally-stable\n * `useRouterState` select — the reference only changes when the pending set\n * actually changes, so the timer legitimately resets per navigation.\n */\n/**\n * The delayed-spinner gate: mirrors `pendingIds` but only after they have\n * stayed pending for `delayMs` continuously; clears the moment the pending\n * set empties. The state is a STRING KEY, not the array — array-typed state\n * set from an effect re-triggers renders (a fresh reference every time)\n * and loops; a key string with the React bail-out (same value → no\n * re-render) cannot. `key` is stable across re-renders as long as the\n * pending SET is the same, even if TanStack hands out a fresh array\n * reference per render.\n */\nfunction useStalledIds(pendingIds: string[], delayMs: number): string[] {\n\tconst key = pendingIds.join(\"|\");\n\tconst [stalledKey, setStalledKey] = useState(\"\");\n\tuseEffect(() => {\n\t\tif (key === \"\") {\n\t\t\tsetStalledKey(prev => (prev === \"\" ? prev : \"\"));\n\t\t\treturn;\n\t\t}\n\t\tconst timer = setTimeout(() => setStalledKey(key), delayMs);\n\t\treturn () => {\n\t\t\tclearTimeout(timer);\n\t\t};\n\t}, [key, delayMs]);\n\treturn stalledKey === key && key !== \"\" ? pendingIds : [];\n}\n"],"mappings":"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"}
package/dist/vkui.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { ModalCardSurface, ModalEntry, ModalEntryComponent, ModalPageSurface, Modals, ModalsProps } from "./vkui/modals.js";
2
2
  import { PopoutEntry, Popouts, useHidePopout, usePopoutState, useShowPopout } from "./vkui/popouts.js";
3
- import { VkView, VkViewProps, panelIdOf, storyKeyOf } from "./vkui/vk-view.js";
3
+ import { DefaultPanelPendingComponent, VkView, VkViewProps, panelIdOf, storyKeyOf } from "./vkui/vk-view.js";
4
4
  import { NotFound } from "./vkui/not-found.js";
5
- export { ModalCardSurface, type ModalEntry, type ModalEntryComponent, ModalPageSurface, Modals, type ModalsProps, NotFound, type PopoutEntry, Popouts, VkView, type VkViewProps, panelIdOf, storyKeyOf, useHidePopout, usePopoutState, useShowPopout };
5
+ export { DefaultPanelPendingComponent, ModalCardSurface, type ModalEntry, type ModalEntryComponent, ModalPageSurface, Modals, type ModalsProps, NotFound, type PopoutEntry, Popouts, VkView, type VkViewProps, panelIdOf, storyKeyOf, useHidePopout, usePopoutState, useShowPopout };
package/dist/vkui.js CHANGED
@@ -1,5 +1,5 @@
1
- import { VkView, panelIdOf, storyKeyOf } from "./vkui/vk-view.js";
1
+ import { DefaultPanelPendingComponent, VkView, panelIdOf, storyKeyOf } from "./vkui/vk-view.js";
2
2
  import { ModalCardSurface, ModalPageSurface, Modals } from "./vkui/modals.js";
3
3
  import { Popouts, useHidePopout, usePopoutState, useShowPopout } from "./vkui/popouts.js";
4
4
  import { NotFound } from "./vkui/not-found.js";
5
- export { ModalCardSurface, ModalPageSurface, Modals, NotFound, Popouts, VkView, panelIdOf, storyKeyOf, useHidePopout, usePopoutState, useShowPopout };
5
+ export { DefaultPanelPendingComponent, ModalCardSurface, ModalPageSurface, Modals, NotFound, Popouts, VkView, panelIdOf, storyKeyOf, useHidePopout, usePopoutState, useShowPopout };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vkma.dev/router",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "VK Mini Apps router on top of TanStack Router — VKUI views/panels/modals/popouts, VK history and bridge sync",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/preset.ts CHANGED
@@ -36,6 +36,7 @@ export {
36
36
  Popouts,
37
37
  VkView,
38
38
  NotFound,
39
+ DefaultPanelPendingComponent,
39
40
  useShowPopout,
40
41
  useHidePopout,
41
42
  usePopoutState,
@@ -149,11 +149,14 @@ export function VkView({ view, nav, onSwipeBack }: VkViewProps) {
149
149
  // an explicit pendingComponent is deliberate — no grace delay
150
150
  content = <Pending />;
151
151
  } else if (isPending) {
152
- content = <PanelSpinnerFallback />;
152
+ content = <DefaultPanelPendingComponent />;
153
153
  } else {
154
- // chunk in flight, inside the grace window: an empty panel
155
- // beats a spinner that would flash for a blink
156
- content = null;
154
+ // chunk in flight, inside the grace window: a header stub beats
155
+ // a spinner that would flash for a blink — and beats an empty
156
+ // panel: every panel SEPARATES with its own <PanelHeader>, so
157
+ // the stub paints the final layout slot and the arriving
158
+ // content fills in under it, nothing jumps
159
+ content = <PanelHeader />;
157
160
  }
158
161
  return (
159
162
  <Panel key={id} nav={id}>
@@ -165,10 +168,16 @@ export function VkView({ view, nav, onSwipeBack }: VkViewProps) {
165
168
  );
166
169
  }
167
170
 
168
- function PanelSpinnerFallback() {
169
- // The canonical VKUI loading screen: a header stub keeps the layout
170
- // structure while the lazy chunk is in flight, and PanelSpinner centers
171
- // itself inside the panel (a bare Spinner floats at the top of a void).
171
+ /**
172
+ * The canonical pending screen for a panel: a header stub keeps the layout
173
+ * structure while the lazy chunk is in flight, and PanelSpinner centers
174
+ * itself inside the panel (a bare Spinner floats at the top of a void).
175
+ *
176
+ * `VkView` renders this whenever a chunk is genuinely absent past the grace
177
+ * delay (see the pending branches in the render above); pass it as a
178
+ * panel's `pendingComponent` to opt out of the grace delay instead.
179
+ */
180
+ export function DefaultPanelPendingComponent() {
172
181
  return (
173
182
  <>
174
183
  <PanelHeader />
package/src/vkui.ts CHANGED
@@ -1,4 +1,10 @@
1
- export { VkView, panelIdOf, storyKeyOf, type VkViewProps } from "./vkui/vk-view.tsx";
1
+ export {
2
+ VkView,
3
+ DefaultPanelPendingComponent,
4
+ panelIdOf,
5
+ storyKeyOf,
6
+ type VkViewProps,
7
+ } from "./vkui/vk-view.tsx";
2
8
  export {
3
9
  Modals,
4
10
  ModalPageSurface,