@vkma.dev/router 0.1.2 → 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/link.d.ts +52 -0
- package/dist/preset/link.d.ts.map +1 -0
- package/dist/preset/link.js +49 -0
- package/dist/preset/link.js.map +1 -0
- package/dist/preset.d.ts +4 -3
- package/dist/preset.js +3 -2
- package/dist/vkui/vk-view.d.ts +13 -1
- package/dist/vkui/vk-view.d.ts.map +1 -1
- package/dist/vkui/vk-view.js +56 -6
- package/dist/vkui/vk-view.js.map +1 -1
- package/dist/vkui.d.ts +2 -2
- package/dist/vkui.js +2 -2
- package/package.json +1 -1
- package/src/preset/link.tsx +111 -0
- package/src/preset.ts +3 -1
- package/src/vkui/vk-view.tsx +94 -25
- package/src/vkui.ts +7 -1
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { PanelPage } from "./navigator.js";
|
|
2
|
+
import { CSSProperties, ComponentType, MouseEvent, ReactNode } from "react";
|
|
3
|
+
//#region src/preset/link.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Router-link props on top of a Page object — the typed counterpart of
|
|
6
|
+
* `navigator.push(page, params)`. The `page` is a panel definition/handle
|
|
7
|
+
* (its phantom `types.params` drives the `params` prop type).
|
|
8
|
+
*/
|
|
9
|
+
export interface VkLinkProps<TParams extends Record<string, string> = Record<string, string>> {
|
|
10
|
+
/** The typed page target: a panel definition/handle or a bare PanelPage. */
|
|
11
|
+
page: PanelPage<TParams>;
|
|
12
|
+
/** Path params — required for pages with `$param` segments. */
|
|
13
|
+
params?: TParams;
|
|
14
|
+
/**
|
|
15
|
+
* Route preload trigger. Default `"intent"` — hover/focus on desktop,
|
|
16
|
+
* touchstart on mobile: the chunk (and loader data) is fetched BEFORE
|
|
17
|
+
* the tap, so the arriving panel paints instantly.
|
|
18
|
+
*/
|
|
19
|
+
preload?: false | "intent" | "render" | "viewport";
|
|
20
|
+
/** Replace the history entry instead of pushing (tab/tabbar switches). */
|
|
21
|
+
replace?: boolean;
|
|
22
|
+
children?: ReactNode;
|
|
23
|
+
className?: string;
|
|
24
|
+
style?: CSSProperties;
|
|
25
|
+
title?: string;
|
|
26
|
+
target?: string;
|
|
27
|
+
rel?: string;
|
|
28
|
+
disabled?: boolean;
|
|
29
|
+
"aria-label"?: string;
|
|
30
|
+
onClick?: (event: MouseEvent<HTMLAnchorElement>) => void;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Turn ANY component (a VKUI `CellButton`, `RichCell`, your own button)
|
|
34
|
+
* into a router link: client-side navigation plus route preloading on
|
|
35
|
+
* hover/focus/touchstart (`preload="intent"` by default — the chunk and
|
|
36
|
+
* loader data are fetched BEFORE the tap). Targets are the typed Page
|
|
37
|
+
* objects `navigator.push` accepts:
|
|
38
|
+
*
|
|
39
|
+
* ```tsx
|
|
40
|
+
* const CellButtonLink = createVkLink(CellButton);
|
|
41
|
+
*
|
|
42
|
+
* <CellButtonLink page={postPanel} params={{ id: "1" }}>Открыть</CellButtonLink>
|
|
43
|
+
* ```
|
|
44
|
+
*
|
|
45
|
+
* The host component receives the anchor `href` plus the navigation and
|
|
46
|
+
* preload handlers — VKUI buttons/cells spread unknown props onto their
|
|
47
|
+
* interactive element, so no extra wiring is needed. A custom `onClick`
|
|
48
|
+
* composes with the navigation handler (yours first, then navigate).
|
|
49
|
+
*/
|
|
50
|
+
export declare function createVkLink<TCompProps>(Comp: ComponentType<TCompProps>): <TParams extends Record<string, string>>(props: TCompProps & VkLinkProps<TParams>) => ReactNode;
|
|
51
|
+
//#endregion
|
|
52
|
+
//# sourceMappingURL=link.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"link.d.ts","names":[],"sources":["../../src/preset/link.tsx"],"mappings":";;;;;;;;iBAeiB,YAAY,gBAAgB,yBAAyB;;EAErE,MAAM,UAAU;;EAEhB,SAAS;;;;;;EAMT;;EAEA;EACA,WAAW;EACX;EACA,QAAQ;EACR;EACA;EACA;EACA;EACA;EACA,WAAW,OAAO,WAAW;;;;;;;;;;;;;;;;;;;;wBAgDd,aAAa,YAC5B,MAAM,cAAc,eACjB,gBAAgB,wBAAwB,OAAO,aAAa,YAAY,aAAa"}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { useLinkProps } from "@tanstack/react-router";
|
|
2
|
+
import { createElement } from "react";
|
|
3
|
+
//#region src/preset/link.tsx
|
|
4
|
+
/** `"index"` and an optional leading slash normalize to the router path (navigator parity). */
|
|
5
|
+
function vkLinkTarget(page) {
|
|
6
|
+
return page.path === "index" ? "/" : `/${page.path.replace(/^\//, "")}`;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Turn ANY component (a VKUI `CellButton`, `RichCell`, your own button)
|
|
10
|
+
* into a router link: client-side navigation plus route preloading on
|
|
11
|
+
* hover/focus/touchstart (`preload="intent"` by default — the chunk and
|
|
12
|
+
* loader data are fetched BEFORE the tap). Targets are the typed Page
|
|
13
|
+
* objects `navigator.push` accepts:
|
|
14
|
+
*
|
|
15
|
+
* ```tsx
|
|
16
|
+
* const CellButtonLink = createVkLink(CellButton);
|
|
17
|
+
*
|
|
18
|
+
* <CellButtonLink page={postPanel} params={{ id: "1" }}>Открыть</CellButtonLink>
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* The host component receives the anchor `href` plus the navigation and
|
|
22
|
+
* preload handlers — VKUI buttons/cells spread unknown props onto their
|
|
23
|
+
* interactive element, so no extra wiring is needed. A custom `onClick`
|
|
24
|
+
* composes with the navigation handler (yours first, then navigate).
|
|
25
|
+
*/
|
|
26
|
+
function createVkLink(Comp) {
|
|
27
|
+
return function VkLink(props) {
|
|
28
|
+
const { page, params, preload = "intent", replace, onClick, ...host } = props;
|
|
29
|
+
const options = {
|
|
30
|
+
to: vkLinkTarget(page),
|
|
31
|
+
params,
|
|
32
|
+
preload,
|
|
33
|
+
replace
|
|
34
|
+
};
|
|
35
|
+
const anchor = useLinkProps(options);
|
|
36
|
+
return createElement(Comp, {
|
|
37
|
+
...host,
|
|
38
|
+
...anchor,
|
|
39
|
+
onClick: (event) => {
|
|
40
|
+
onClick?.(event);
|
|
41
|
+
if (!event.defaultPrevented) anchor.onClick?.(event);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
//#endregion
|
|
47
|
+
export { createVkLink, vkLinkTarget };
|
|
48
|
+
|
|
49
|
+
//# sourceMappingURL=link.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"link.js","names":[],"sources":["../../src/preset/link.tsx"],"sourcesContent":["import { useLinkProps, type UseLinkPropsOptions } from \"@tanstack/react-router\";\nimport {\n\tcreateElement,\n\ttype ComponentType,\n\ttype CSSProperties,\n\ttype MouseEvent,\n\ttype ReactNode,\n} from \"react\";\nimport { type PanelPage } from \"./navigator.ts\";\n\n/**\n * Router-link props on top of a Page object — the typed counterpart of\n * `navigator.push(page, params)`. The `page` is a panel definition/handle\n * (its phantom `types.params` drives the `params` prop type).\n */\nexport interface VkLinkProps<TParams extends Record<string, string> = Record<string, string>> {\n\t/** The typed page target: a panel definition/handle or a bare PanelPage. */\n\tpage: PanelPage<TParams>;\n\t/** Path params — required for pages with `$param` segments. */\n\tparams?: TParams;\n\t/**\n\t * Route preload trigger. Default `\"intent\"` — hover/focus on desktop,\n\t * touchstart on mobile: the chunk (and loader data) is fetched BEFORE\n\t * the tap, so the arriving panel paints instantly.\n\t */\n\tpreload?: false | \"intent\" | \"render\" | \"viewport\";\n\t/** Replace the history entry instead of pushing (tab/tabbar switches). */\n\treplace?: boolean;\n\tchildren?: ReactNode;\n\tclassName?: string;\n\tstyle?: CSSProperties;\n\ttitle?: string;\n\ttarget?: string;\n\trel?: string;\n\tdisabled?: boolean;\n\t\"aria-label\"?: string;\n\tonClick?: (event: MouseEvent<HTMLAnchorElement>) => void;\n}\n\n/** `\"index\"` and an optional leading slash normalize to the router path (navigator parity). */\nexport function vkLinkTarget(page: PanelPage): string {\n\treturn page.path === \"index\" ? \"/\" : `/${page.path.replace(/^\\//, \"\")}`;\n}\n\nexport interface SplitVkLinkProps {\n\tto: string;\n\tparams?: Record<string, string>;\n\tpreload: false | \"intent\" | \"render\" | \"viewport\";\n\treplace?: boolean;\n\thost: Record<string, unknown>;\n}\n\n/** Pure props slicing: router options vs what the host component receives. */\nexport function splitVkLinkProps<TParams extends Record<string, string>>(\n\tprops: VkLinkProps<TParams>,\n): SplitVkLinkProps {\n\tconst { page, params, preload = \"intent\", replace, ...host } = props;\n\treturn {\n\t\tto: vkLinkTarget(page),\n\t\tparams,\n\t\tpreload,\n\t\treplace,\n\t\thost: host as Record<string, unknown>,\n\t};\n}\n\n/**\n * Turn ANY component (a VKUI `CellButton`, `RichCell`, your own button)\n * into a router link: client-side navigation plus route preloading on\n * hover/focus/touchstart (`preload=\"intent\"` by default — the chunk and\n * loader data are fetched BEFORE the tap). Targets are the typed Page\n * objects `navigator.push` accepts:\n *\n * ```tsx\n * const CellButtonLink = createVkLink(CellButton);\n *\n * <CellButtonLink page={postPanel} params={{ id: \"1\" }}>Открыть</CellButtonLink>\n * ```\n *\n * The host component receives the anchor `href` plus the navigation and\n * preload handlers — VKUI buttons/cells spread unknown props onto their\n * interactive element, so no extra wiring is needed. A custom `onClick`\n * composes with the navigation handler (yours first, then navigate).\n */\nexport function createVkLink<TCompProps>(\n\tComp: ComponentType<TCompProps>,\n): <TParams extends Record<string, string>>(props: TCompProps & VkLinkProps<TParams>) => ReactNode {\n\treturn function VkLink<TParams extends Record<string, string>>(\n\t\tprops: TCompProps & VkLinkProps<TParams>,\n\t): ReactNode {\n\t\tconst { page, params, preload = \"intent\", replace, onClick, ...host } = props;\n\t\tconst options: UseLinkPropsOptions = {\n\t\t\tto: vkLinkTarget(page),\n\t\t\tparams,\n\t\t\tpreload,\n\t\t\treplace,\n\t\t} as UseLinkPropsOptions;\n\t\tconst anchor = useLinkProps(options);\n\t\treturn createElement(Comp, {\n\t\t\t...host,\n\t\t\t...anchor,\n\t\t\tonClick: (event: MouseEvent<HTMLAnchorElement>) => {\n\t\t\t\tonClick?.(event);\n\t\t\t\t// TanStack's handler preventDefaults and navigates client-side\n\t\t\t\tif (!event.defaultPrevented) {\n\t\t\t\t\tanchor.onClick?.(event);\n\t\t\t\t}\n\t\t\t},\n\t\t} as TCompProps);\n\t};\n}\n"],"mappings":";;;;AAwCA,SAAgB,aAAa,MAAyB;CACrD,OAAO,KAAK,SAAS,UAAU,MAAM,IAAI,KAAK,KAAK,QAAQ,OAAO,EAAE;AACrE;;;;;;;;;;;;;;;;;;;AA0CA,SAAgB,aACf,MACkG;CAClG,OAAO,SAAS,OACf,OACY;EACZ,MAAM,EAAE,MAAM,QAAQ,UAAU,UAAU,SAAS,SAAS,GAAG,SAAS;EACxE,MAAM,UAA+B;GACpC,IAAI,aAAa,IAAI;GACrB;GACA;GACA;EACD;EACA,MAAM,SAAS,aAAa,OAAO;EACnC,OAAO,cAAc,MAAM;GAC1B,GAAG;GACH,GAAG;GACH,UAAU,UAAyC;IAClD,UAAU,KAAK;IAEf,IAAI,CAAC,MAAM,kBACV,OAAO,UAAU,KAAK;GAExB;EACD,CAAe;CAChB;AACD"}
|
package/dist/preset.d.ts
CHANGED
|
@@ -8,10 +8,11 @@ import { PanelDefinition, definePanel, panelRouteId } from "./preset/define-pane
|
|
|
8
8
|
import { ViewDefinition, defineView } from "./preset/define-view.js";
|
|
9
9
|
import { AppHandle, AppOptions, ModalComponent, ModalDeclaration, ModalEntryComponent, PanelHandle, PanelHandlesOf, PanelOptions, TypedAppHandle, ViewOptions, createApp, panel } from "./preset/builders.js";
|
|
10
10
|
import { AppRootShell, useActiveStory, useInflate } from "./preset/app.js";
|
|
11
|
+
import { PanelPage, VkNavigator, useNavigator } from "./preset/navigator.js";
|
|
12
|
+
import { VkLinkProps, createVkLink } from "./preset/link.js";
|
|
11
13
|
import { deriveInflateChain } from "./preset/inflate.js";
|
|
12
|
-
import {
|
|
13
|
-
import { VkView } from "./vkui/vk-view.js";
|
|
14
|
+
import { DefaultPanelPendingComponent, VkView } from "./vkui/vk-view.js";
|
|
14
15
|
import { NotFound } from "./vkui/not-found.js";
|
|
15
16
|
import "./vkui.js";
|
|
16
17
|
import { PanelRef, useEnableSwipeBack, useModalProps, usePanelLoaderData, usePanelParams, usePanelSearch, usePanelsHistory } from "./react/hooks.js";
|
|
17
|
-
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 PanelRef, Popouts, type TypedAppHandle, type ViewDefinition, type ViewOptions, type VkBridgeLike, type VkNavigator, VkView, createApp, createBridgeSync, createSwipeBackManager, createVkHistory, 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";
|
|
@@ -12,5 +12,6 @@ import { AppRootShell, useActiveStory, useInflate } from "./preset/app.js";
|
|
|
12
12
|
import { definePanel, panelRouteId } from "./preset/define-panel.js";
|
|
13
13
|
import { defineView } from "./preset/define-view.js";
|
|
14
14
|
import { createApp, panel } from "./preset/builders.js";
|
|
15
|
+
import { createVkLink } from "./preset/link.js";
|
|
15
16
|
import { useNavigator } from "./preset/navigator.js";
|
|
16
|
-
export { AppRootShell, ModalCardSurface, ModalPageSurface, Modals, NotFound, Popouts, VkView, createApp, createBridgeSync, createSwipeBackManager, createVkHistory, 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 };
|
package/dist/vkui/vk-view.d.ts
CHANGED
|
@@ -23,11 +23,23 @@ export interface VkViewProps {
|
|
|
23
23
|
* the animation direction from the children order — a plain <Outlet>
|
|
24
24
|
* breaks both. A lazy panel's `options.component` is undefined until its
|
|
25
25
|
* chunk resolves (TanStack merges loaded options in place), so the panel
|
|
26
|
-
* renders a spinner fallback until then
|
|
26
|
+
* renders a spinner fallback until then — but only after a short grace
|
|
27
|
+
* delay: a fast chunk load must not flash a spinner for a frame or two,
|
|
28
|
+
* the panel just fills in seamlessly. For the same reason TanStack's
|
|
27
29
|
* Outlet-driven `pendingComponent` swap never happens on its own — while
|
|
28
30
|
* the panel's match is pending (loader/beforeLoad), the route's
|
|
29
31
|
* `pendingComponent` renders instead of the component.
|
|
30
32
|
*/
|
|
31
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;
|
|
32
44
|
//#endregion
|
|
33
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
|
|
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"}
|
package/dist/vkui/vk-view.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { usePanelsHistory } from "../react/hooks.js";
|
|
2
2
|
import { useRouterState } from "@tanstack/react-router";
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
3
4
|
import { Panel, PanelHeader, PanelSpinner, View } from "@vkontakte/vkui";
|
|
4
5
|
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
5
6
|
//#region src/vkui/vk-view.tsx
|
|
@@ -34,7 +35,9 @@ function storyKeyOf(routeId) {
|
|
|
34
35
|
* the animation direction from the children order — a plain <Outlet>
|
|
35
36
|
* breaks both. A lazy panel's `options.component` is undefined until its
|
|
36
37
|
* chunk resolves (TanStack merges loaded options in place), so the panel
|
|
37
|
-
* renders a spinner fallback until then
|
|
38
|
+
* renders a spinner fallback until then — but only after a short grace
|
|
39
|
+
* delay: a fast chunk load must not flash a spinner for a frame or two,
|
|
40
|
+
* the panel just fills in seamlessly. For the same reason TanStack's
|
|
38
41
|
* Outlet-driven `pendingComponent` swap never happens on its own — while
|
|
39
42
|
* the panel's match is pending (loader/beforeLoad), the route's
|
|
40
43
|
* `pendingComponent` renders instead of the component.
|
|
@@ -42,7 +45,7 @@ function storyKeyOf(routeId) {
|
|
|
42
45
|
function VkView({ view, nav, onSwipeBack }) {
|
|
43
46
|
useMatchSummaries();
|
|
44
47
|
const matchedRouteIds = useRouterState({ select: (state) => state.matches.map((match) => match.routeId) });
|
|
45
|
-
const
|
|
48
|
+
const stalledRouteIds = useStalledIds(useRouterState({ select: (state) => state.matches.filter((match) => match.status === "pending").map((match) => String(match.routeId)) }), 200);
|
|
46
49
|
const notFoundRouteIds = useRouterState({ select: (state) => state.matches.filter((match) => match.status === "notFound").map((match) => String(match.routeId)) });
|
|
47
50
|
const activeRouteId = deepestPanelMatchId(view, matchedRouteIds);
|
|
48
51
|
const activeChild = view.children.find((child) => child.id === activeRouteId) ?? view.children[0];
|
|
@@ -57,19 +60,66 @@ function VkView({ view, nav, onSwipeBack }) {
|
|
|
57
60
|
const id = panelIdOf(child);
|
|
58
61
|
const Component = child.options.component;
|
|
59
62
|
const Pending = child.options.pendingComponent;
|
|
60
|
-
const isPending =
|
|
63
|
+
const isPending = stalledRouteIds.includes(String(child.id));
|
|
61
64
|
const isNotFound = notFoundRouteIds.includes(String(child.id));
|
|
65
|
+
let content;
|
|
66
|
+
if (isNotFound) content = null;
|
|
67
|
+
else if (Component !== void 0) content = /* @__PURE__ */ jsx(Component, {});
|
|
68
|
+
else if (isPending && Pending !== void 0) content = /* @__PURE__ */ jsx(Pending, {});
|
|
69
|
+
else if (isPending) content = /* @__PURE__ */ jsx(DefaultPanelPendingComponent, {});
|
|
70
|
+
else content = /* @__PURE__ */ jsx(PanelHeader, {});
|
|
62
71
|
return /* @__PURE__ */ jsx(Panel, {
|
|
63
72
|
nav: id,
|
|
64
|
-
children:
|
|
73
|
+
children: content
|
|
65
74
|
}, id);
|
|
66
75
|
})
|
|
67
76
|
});
|
|
68
77
|
}
|
|
69
|
-
|
|
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() {
|
|
70
88
|
return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(PanelHeader, {}), /* @__PURE__ */ jsx(PanelSpinner, { size: "l" })] });
|
|
71
89
|
}
|
|
90
|
+
/**
|
|
91
|
+
* The delayed-spinner gate: mirrors `pendingIds` but only after they have
|
|
92
|
+
* stayed pending for `delayMs` continuously; clears the moment the pending
|
|
93
|
+
* set empties. `pendingRouteIds` comes from a structurally-stable
|
|
94
|
+
* `useRouterState` select — the reference only changes when the pending set
|
|
95
|
+
* actually changes, so the timer legitimately resets per navigation.
|
|
96
|
+
*/
|
|
97
|
+
/**
|
|
98
|
+
* The delayed-spinner gate: mirrors `pendingIds` but only after they have
|
|
99
|
+
* stayed pending for `delayMs` continuously; clears the moment the pending
|
|
100
|
+
* set empties. The state is a STRING KEY, not the array — array-typed state
|
|
101
|
+
* set from an effect re-triggers renders (a fresh reference every time)
|
|
102
|
+
* and loops; a key string with the React bail-out (same value → no
|
|
103
|
+
* re-render) cannot. `key` is stable across re-renders as long as the
|
|
104
|
+
* pending SET is the same, even if TanStack hands out a fresh array
|
|
105
|
+
* reference per render.
|
|
106
|
+
*/
|
|
107
|
+
function useStalledIds(pendingIds, delayMs) {
|
|
108
|
+
const key = pendingIds.join("|");
|
|
109
|
+
const [stalledKey, setStalledKey] = useState("");
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
if (key === "") {
|
|
112
|
+
setStalledKey((prev) => prev === "" ? prev : "");
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const timer = setTimeout(() => setStalledKey(key), delayMs);
|
|
116
|
+
return () => {
|
|
117
|
+
clearTimeout(timer);
|
|
118
|
+
};
|
|
119
|
+
}, [key, delayMs]);
|
|
120
|
+
return stalledKey === key && key !== "" ? pendingIds : [];
|
|
121
|
+
}
|
|
72
122
|
//#endregion
|
|
73
|
-
export { VkView, panelIdOf, storyKeyOf };
|
|
123
|
+
export { DefaultPanelPendingComponent, VkView, panelIdOf, storyKeyOf };
|
|
74
124
|
|
|
75
125
|
//# sourceMappingURL=vk-view.js.map
|
package/dist/vkui/vk-view.js.map
CHANGED
|
@@ -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 { type ComponentType } 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. 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// 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\tconst isPending = pendingRouteIds.includes(String(child.id));\n\t\t\t\tconst isNotFound = notFoundRouteIds.includes(String(child.id));\n\t\t\t\t// pending without a pendingComponent renders the FALLBACK, never\n\t\t\t\t// the component: a lazy chunk may have already merged its\n\t\t\t\t// component while the match is still pending (or parked on a\n\t\t\t\t// notFound boundary up the tree) — the component's data guards\n\t\t\t\t// assume a resolvable match and would throw in a hidden panel\n\t\t\t\treturn (\n\t\t\t\t\t<Panel key={id} nav={id}>\n\t\t\t\t\t\t{isPending ? (\n\t\t\t\t\t\t\tPending !== undefined ? (\n\t\t\t\t\t\t\t\t<Pending />\n\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t<PanelSpinnerFallback />\n\t\t\t\t\t\t\t)\n\t\t\t\t\t\t) : isNotFound ? (\n\t\t\t\t\t\t\t<PanelSpinnerFallback />\n\t\t\t\t\t\t) : Component ? (\n\t\t\t\t\t\t\t<Component />\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t<PanelSpinnerFallback />\n\t\t\t\t\t\t)}\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"],"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;;;;;;;;;;;;;AAuBA,SAAgB,OAAO,EAAE,MAAM,KAAK,eAA4B;CAC/D,kBAAkB;CAClB,MAAM,kBAAkB,eAAe,EACtC,SAAQ,UAAS,MAAM,QAAQ,KAAI,UAAS,MAAM,OAAO,EAC1D,CAAC;CAKD,MAAM,kBAAkB,eAAe,EACtC,SAAQ,UACP,MAAM,QACJ,QAAO,UAAU,MAA8B,WAAW,SAAS,CAAC,CACpE,KAAI,UAAS,OAAO,MAAM,OAAO,CAAC,EACtC,CAAC;CAMD,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;GAC9B,MAAM,YAAY,gBAAgB,SAAS,OAAO,MAAM,EAAE,CAAC;GAC3D,MAAM,aAAa,iBAAiB,SAAS,OAAO,MAAM,EAAE,CAAC;GAM7D,OACC,oBAAC,OAAD;IAAgB,KAAK;IACnB,UAAA,YACA,YAAY,KAAA,IACX,oBAAC,SAAD,CAAU,CAAA,IAEV,oBAAC,sBAAD,CAAuB,CAAA,IAErB,aACH,oBAAC,sBAAD,CAAuB,CAAA,IACpB,YACH,oBAAC,WAAD,CAAY,CAAA,IAEZ,oBAAC,sBAAD,CAAuB,CAAA;GAElB,GAdK,EAcL;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"}
|
|
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":";;;;;;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,8BAAD,CAA+B,CAAA;QAOzC,UAAU,oBAAC,aAAD,CAAc,CAAA;GAEzB,OACC,oBAAC,OAAD;IAAgB,KAAK;IACnB,UAAA;GACK,GAFK,EAEL;EAET,CAAC;CACI,CAAA;AAER;;;;;;;;;;AAWA,SAAgB,+BAA+B;CAC9C,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"}
|
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
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { useLinkProps, type UseLinkPropsOptions } from "@tanstack/react-router";
|
|
2
|
+
import {
|
|
3
|
+
createElement,
|
|
4
|
+
type ComponentType,
|
|
5
|
+
type CSSProperties,
|
|
6
|
+
type MouseEvent,
|
|
7
|
+
type ReactNode,
|
|
8
|
+
} from "react";
|
|
9
|
+
import { type PanelPage } from "./navigator.ts";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Router-link props on top of a Page object — the typed counterpart of
|
|
13
|
+
* `navigator.push(page, params)`. The `page` is a panel definition/handle
|
|
14
|
+
* (its phantom `types.params` drives the `params` prop type).
|
|
15
|
+
*/
|
|
16
|
+
export interface VkLinkProps<TParams extends Record<string, string> = Record<string, string>> {
|
|
17
|
+
/** The typed page target: a panel definition/handle or a bare PanelPage. */
|
|
18
|
+
page: PanelPage<TParams>;
|
|
19
|
+
/** Path params — required for pages with `$param` segments. */
|
|
20
|
+
params?: TParams;
|
|
21
|
+
/**
|
|
22
|
+
* Route preload trigger. Default `"intent"` — hover/focus on desktop,
|
|
23
|
+
* touchstart on mobile: the chunk (and loader data) is fetched BEFORE
|
|
24
|
+
* the tap, so the arriving panel paints instantly.
|
|
25
|
+
*/
|
|
26
|
+
preload?: false | "intent" | "render" | "viewport";
|
|
27
|
+
/** Replace the history entry instead of pushing (tab/tabbar switches). */
|
|
28
|
+
replace?: boolean;
|
|
29
|
+
children?: ReactNode;
|
|
30
|
+
className?: string;
|
|
31
|
+
style?: CSSProperties;
|
|
32
|
+
title?: string;
|
|
33
|
+
target?: string;
|
|
34
|
+
rel?: string;
|
|
35
|
+
disabled?: boolean;
|
|
36
|
+
"aria-label"?: string;
|
|
37
|
+
onClick?: (event: MouseEvent<HTMLAnchorElement>) => void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** `"index"` and an optional leading slash normalize to the router path (navigator parity). */
|
|
41
|
+
export function vkLinkTarget(page: PanelPage): string {
|
|
42
|
+
return page.path === "index" ? "/" : `/${page.path.replace(/^\//, "")}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface SplitVkLinkProps {
|
|
46
|
+
to: string;
|
|
47
|
+
params?: Record<string, string>;
|
|
48
|
+
preload: false | "intent" | "render" | "viewport";
|
|
49
|
+
replace?: boolean;
|
|
50
|
+
host: Record<string, unknown>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Pure props slicing: router options vs what the host component receives. */
|
|
54
|
+
export function splitVkLinkProps<TParams extends Record<string, string>>(
|
|
55
|
+
props: VkLinkProps<TParams>,
|
|
56
|
+
): SplitVkLinkProps {
|
|
57
|
+
const { page, params, preload = "intent", replace, ...host } = props;
|
|
58
|
+
return {
|
|
59
|
+
to: vkLinkTarget(page),
|
|
60
|
+
params,
|
|
61
|
+
preload,
|
|
62
|
+
replace,
|
|
63
|
+
host: host as Record<string, unknown>,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Turn ANY component (a VKUI `CellButton`, `RichCell`, your own button)
|
|
69
|
+
* into a router link: client-side navigation plus route preloading on
|
|
70
|
+
* hover/focus/touchstart (`preload="intent"` by default — the chunk and
|
|
71
|
+
* loader data are fetched BEFORE the tap). Targets are the typed Page
|
|
72
|
+
* objects `navigator.push` accepts:
|
|
73
|
+
*
|
|
74
|
+
* ```tsx
|
|
75
|
+
* const CellButtonLink = createVkLink(CellButton);
|
|
76
|
+
*
|
|
77
|
+
* <CellButtonLink page={postPanel} params={{ id: "1" }}>Открыть</CellButtonLink>
|
|
78
|
+
* ```
|
|
79
|
+
*
|
|
80
|
+
* The host component receives the anchor `href` plus the navigation and
|
|
81
|
+
* preload handlers — VKUI buttons/cells spread unknown props onto their
|
|
82
|
+
* interactive element, so no extra wiring is needed. A custom `onClick`
|
|
83
|
+
* composes with the navigation handler (yours first, then navigate).
|
|
84
|
+
*/
|
|
85
|
+
export function createVkLink<TCompProps>(
|
|
86
|
+
Comp: ComponentType<TCompProps>,
|
|
87
|
+
): <TParams extends Record<string, string>>(props: TCompProps & VkLinkProps<TParams>) => ReactNode {
|
|
88
|
+
return function VkLink<TParams extends Record<string, string>>(
|
|
89
|
+
props: TCompProps & VkLinkProps<TParams>,
|
|
90
|
+
): ReactNode {
|
|
91
|
+
const { page, params, preload = "intent", replace, onClick, ...host } = props;
|
|
92
|
+
const options: UseLinkPropsOptions = {
|
|
93
|
+
to: vkLinkTarget(page),
|
|
94
|
+
params,
|
|
95
|
+
preload,
|
|
96
|
+
replace,
|
|
97
|
+
} as UseLinkPropsOptions;
|
|
98
|
+
const anchor = useLinkProps(options);
|
|
99
|
+
return createElement(Comp, {
|
|
100
|
+
...host,
|
|
101
|
+
...anchor,
|
|
102
|
+
onClick: (event: MouseEvent<HTMLAnchorElement>) => {
|
|
103
|
+
onClick?.(event);
|
|
104
|
+
// TanStack's handler preventDefaults and navigates client-side
|
|
105
|
+
if (!event.defaultPrevented) {
|
|
106
|
+
anchor.onClick?.(event);
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
} as TCompProps);
|
|
110
|
+
};
|
|
111
|
+
}
|
package/src/preset.ts
CHANGED
|
@@ -10,8 +10,9 @@ export {
|
|
|
10
10
|
type ViewOptions,
|
|
11
11
|
} from "./preset/builders.tsx";
|
|
12
12
|
export { AppRootShell, useActiveStory, useInflate } from "./preset/app.tsx";
|
|
13
|
+
export { createVkLink, type VkLinkProps } from "./preset/link.tsx";
|
|
13
14
|
export { deriveInflateChain } from "./preset/inflate.ts";
|
|
14
|
-
export { useNavigator, type VkNavigator } from "./preset/navigator.ts";
|
|
15
|
+
export { useNavigator, type PanelPage, type VkNavigator } from "./preset/navigator.ts";
|
|
15
16
|
export { definePanel, panelRouteId, type PanelDefinition } from "./preset/define-panel.ts";
|
|
16
17
|
export {
|
|
17
18
|
type ModalComponent,
|
|
@@ -35,6 +36,7 @@ export {
|
|
|
35
36
|
Popouts,
|
|
36
37
|
VkView,
|
|
37
38
|
NotFound,
|
|
39
|
+
DefaultPanelPendingComponent,
|
|
38
40
|
useShowPopout,
|
|
39
41
|
useHidePopout,
|
|
40
42
|
usePopoutState,
|
package/src/vkui/vk-view.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { AnyRoute } from "@tanstack/react-router";
|
|
2
2
|
import { useRouterState } from "@tanstack/react-router";
|
|
3
3
|
import { Panel, PanelHeader, PanelSpinner, View } from "@vkontakte/vkui";
|
|
4
|
-
import { type ComponentType } from "react";
|
|
4
|
+
import { useEffect, useState, type ComponentType, type ReactNode } from "react";
|
|
5
5
|
import { usePanelsHistory } from "../react/hooks.ts";
|
|
6
6
|
|
|
7
7
|
export interface PanelStaticData {
|
|
@@ -60,7 +60,9 @@ export interface VkViewProps {
|
|
|
60
60
|
* the animation direction from the children order — a plain <Outlet>
|
|
61
61
|
* breaks both. A lazy panel's `options.component` is undefined until its
|
|
62
62
|
* chunk resolves (TanStack merges loaded options in place), so the panel
|
|
63
|
-
* renders a spinner fallback until then
|
|
63
|
+
* renders a spinner fallback until then — but only after a short grace
|
|
64
|
+
* delay: a fast chunk load must not flash a spinner for a frame or two,
|
|
65
|
+
* the panel just fills in seamlessly. For the same reason TanStack's
|
|
64
66
|
* Outlet-driven `pendingComponent` swap never happens on its own — while
|
|
65
67
|
* the panel's match is pending (loader/beforeLoad), the route's
|
|
66
68
|
* `pendingComponent` renders instead of the component.
|
|
@@ -80,6 +82,13 @@ export function VkView({ view, nav, onSwipeBack }: VkViewProps) {
|
|
|
80
82
|
.filter(match => (match as { status?: string }).status === "pending")
|
|
81
83
|
.map(match => String(match.routeId)),
|
|
82
84
|
});
|
|
85
|
+
// The grace delay: a pending match only earns the spinner fallback after
|
|
86
|
+
// staying pending for this long. Fast lazy-chunk loads (a warm cache, a
|
|
87
|
+
// small bundle) resolve within a frame or two — flashing a spinner for
|
|
88
|
+
// that blink makes every navigation feel broken; showing nothing and
|
|
89
|
+
// letting the panel fill in seamlessly looks instant. Real loaders
|
|
90
|
+
// outlive the delay and still get the fallback.
|
|
91
|
+
const stalledRouteIds = useStalledIds(pendingRouteIds, 200);
|
|
83
92
|
// a notFound match (notFound() from beforeLoad/validateSearch) must NOT
|
|
84
93
|
// render its panel component: the component's data guards assume a valid
|
|
85
94
|
// match, and the shell renders the NotFound screen for this navigation —
|
|
@@ -109,28 +118,49 @@ export function VkView({ view, nav, onSwipeBack }: VkViewProps) {
|
|
|
109
118
|
const id = panelIdOf(child);
|
|
110
119
|
const Component = child.options.component as ComponentType | undefined;
|
|
111
120
|
const Pending = child.options.pendingComponent as ComponentType | undefined;
|
|
112
|
-
|
|
121
|
+
// the grace-gated pending set (useStalledIds): a fast chunk load
|
|
122
|
+
// never shows a spinner, the panel just fills in seamlessly
|
|
123
|
+
const isPending = stalledRouteIds.includes(String(child.id));
|
|
113
124
|
const isNotFound = notFoundRouteIds.includes(String(child.id));
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
//
|
|
125
|
+
// A LOADED COMPONENT ALWAYS WINS — even while its match is still
|
|
126
|
+
// pending. The fallback may only appear when the component is
|
|
127
|
+
// genuinely absent (chunk not merged yet): replacing rendered
|
|
128
|
+
// content with a spinner after it already appeared reads as
|
|
129
|
+
// broken. (Panels reading route data through the /react hooks
|
|
130
|
+
// are safe to render in a transient pending render; raw
|
|
131
|
+
// TanStack data hooks are not — see the AGENTS invariants.)
|
|
132
|
+
let content: ReactNode;
|
|
133
|
+
if (isNotFound) {
|
|
134
|
+
// parked on a notFound boundary up the tree — the component's
|
|
135
|
+
// data guards assume a resolvable match and would throw in a
|
|
136
|
+
// hidden panel; the shell renders the NotFound screen anyway
|
|
137
|
+
content = null;
|
|
138
|
+
} else if (Component !== undefined) {
|
|
139
|
+
// A LOADED COMPONENT ALWAYS WINS — even while its match is
|
|
140
|
+
// still pending. The fallback may only appear when the
|
|
141
|
+
// component is genuinely absent (chunk not merged yet):
|
|
142
|
+
// replacing rendered content with a spinner after it already
|
|
143
|
+
// appeared reads as broken. (Panels reading route data
|
|
144
|
+
// through the /react hooks are safe to render in a transient
|
|
145
|
+
// pending render; raw TanStack data hooks are not — see the
|
|
146
|
+
// AGENTS invariants.)
|
|
147
|
+
content = <Component />;
|
|
148
|
+
} else if (isPending && Pending !== undefined) {
|
|
149
|
+
// an explicit pendingComponent is deliberate — no grace delay
|
|
150
|
+
content = <Pending />;
|
|
151
|
+
} else if (isPending) {
|
|
152
|
+
content = <DefaultPanelPendingComponent />;
|
|
153
|
+
} else {
|
|
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 />;
|
|
160
|
+
}
|
|
119
161
|
return (
|
|
120
162
|
<Panel key={id} nav={id}>
|
|
121
|
-
{
|
|
122
|
-
Pending !== undefined ? (
|
|
123
|
-
<Pending />
|
|
124
|
-
) : (
|
|
125
|
-
<PanelSpinnerFallback />
|
|
126
|
-
)
|
|
127
|
-
) : isNotFound ? (
|
|
128
|
-
<PanelSpinnerFallback />
|
|
129
|
-
) : Component ? (
|
|
130
|
-
<Component />
|
|
131
|
-
) : (
|
|
132
|
-
<PanelSpinnerFallback />
|
|
133
|
-
)}
|
|
163
|
+
{content}
|
|
134
164
|
</Panel>
|
|
135
165
|
);
|
|
136
166
|
})}
|
|
@@ -138,10 +168,16 @@ export function VkView({ view, nav, onSwipeBack }: VkViewProps) {
|
|
|
138
168
|
);
|
|
139
169
|
}
|
|
140
170
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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() {
|
|
145
181
|
return (
|
|
146
182
|
<>
|
|
147
183
|
<PanelHeader />
|
|
@@ -149,3 +185,36 @@ function PanelSpinnerFallback() {
|
|
|
149
185
|
</>
|
|
150
186
|
);
|
|
151
187
|
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The delayed-spinner gate: mirrors `pendingIds` but only after they have
|
|
191
|
+
* stayed pending for `delayMs` continuously; clears the moment the pending
|
|
192
|
+
* set empties. `pendingRouteIds` comes from a structurally-stable
|
|
193
|
+
* `useRouterState` select — the reference only changes when the pending set
|
|
194
|
+
* actually changes, so the timer legitimately resets per navigation.
|
|
195
|
+
*/
|
|
196
|
+
/**
|
|
197
|
+
* The delayed-spinner gate: mirrors `pendingIds` but only after they have
|
|
198
|
+
* stayed pending for `delayMs` continuously; clears the moment the pending
|
|
199
|
+
* set empties. The state is a STRING KEY, not the array — array-typed state
|
|
200
|
+
* set from an effect re-triggers renders (a fresh reference every time)
|
|
201
|
+
* and loops; a key string with the React bail-out (same value → no
|
|
202
|
+
* re-render) cannot. `key` is stable across re-renders as long as the
|
|
203
|
+
* pending SET is the same, even if TanStack hands out a fresh array
|
|
204
|
+
* reference per render.
|
|
205
|
+
*/
|
|
206
|
+
function useStalledIds(pendingIds: string[], delayMs: number): string[] {
|
|
207
|
+
const key = pendingIds.join("|");
|
|
208
|
+
const [stalledKey, setStalledKey] = useState("");
|
|
209
|
+
useEffect(() => {
|
|
210
|
+
if (key === "") {
|
|
211
|
+
setStalledKey(prev => (prev === "" ? prev : ""));
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
const timer = setTimeout(() => setStalledKey(key), delayMs);
|
|
215
|
+
return () => {
|
|
216
|
+
clearTimeout(timer);
|
|
217
|
+
};
|
|
218
|
+
}, [key, delayMs]);
|
|
219
|
+
return stalledKey === key && key !== "" ? pendingIds : [];
|
|
220
|
+
}
|
package/src/vkui.ts
CHANGED