@vkma.dev/router 0.1.1 → 0.1.3

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.
@@ -1 +1 @@
1
- {"version":3,"file":"app.d.ts","names":[],"sources":["../../src/preset/app.tsx"],"mappings":";;;;;;;;;;;;;wBA2DgB,eAAe,KAAK;EAAoB,KAAK;EAAW;oBAAmB,IAAA;;;;;;;;wBA4B3E,eAAe,KAAK;;;;;;;;wBAiBpB,WAAW,KAAK,WAAW"}
1
+ {"version":3,"file":"app.d.ts","names":[],"sources":["../../src/preset/app.tsx"],"mappings":";;;;;;;;;;;;;wBAyDgB,eAAe,KAAK;EAAoB,KAAK;EAAW;oBAAmB,IAAA;;;;;;;;wBA4B3E,eAAe,KAAK;;;;;;;;wBAiBpB,WAAW,KAAK,WAAW"}
@@ -1 +1 @@
1
- {"version":3,"file":"app.js","names":[],"sources":["../../src/preset/app.tsx"],"sourcesContent":["import { useRouter, useRouterState } from \"@tanstack/react-router\";\nimport { useEffect, useRef } from \"react\";\nimport { looseRouteOf } from \"../core/loose-route.ts\";\nimport { Modals } from \"../vkui/modals.tsx\";\nimport { NotFound } from \"../vkui/not-found.tsx\";\nimport { Popouts } from \"../vkui/popouts.tsx\";\nimport { storyKeyOf, VkView } from \"../vkui/vk-view.tsx\";\nimport type { AppHandle } from \"./builders.tsx\";\nimport { deriveInflateChain, interpolatePath } from \"./inflate.ts\";\n\ninterface MatchLike {\n\trouteId?: string;\n\tstatus?: string;\n\t_notFound?: boolean;\n}\n\n/**\n * The view route owning the deepest matched route, or null. The walk starts\n * at the deepest match because the chain depth varies — a modal is a masked\n * CHILD route of its panel, so `/view:feed/feed/comments` must still resolve\n * to the feed view. A notFound match anywhere in the chain wins over\n * everything: the shell must show the NotFound screen, never a story\n * fallback (an unmatched lazy panel's chunk never loads — a fallback story\n * would spin forever).\n */\nexport function owningViewIdOf(\n\tmatches: readonly MatchLike[],\n\tviews: readonly { id: string }[],\n): string | null {\n\tconst notFound = matches.some(\n\t\tmatch =>\n\t\t\tmatch.status === \"notFound\" || (match as { _notFound?: boolean })._notFound === true,\n\t);\n\tif (notFound) {\n\t\treturn null;\n\t}\n\tfor (let i = matches.length - 1; i >= 0; i--) {\n\t\tconst routeId = matches[i]?.routeId ?? \"\";\n\t\tconst view = views.find(\n\t\t\tview => routeId === view.id || routeId.startsWith(`${view.id}/`),\n\t\t);\n\t\tif (view !== undefined) {\n\t\t\treturn view.id;\n\t\t}\n\t}\n\treturn null;\n}\n\n/**\n * Default root shell: the active view + the modal/popout hosts + inflate.\n * The active view is the one containing the deepest matched route\n * (single-view apps always get their only view). Multi-story apps with\n * Epic/Root are built with a custom `root` shell out of the same pieces.\n *\n * An unmatched URL (global 404 or a `notFound()` thrown by `beforeLoad`)\n * renders the VKUI `NotFound` instead of a view — falling back to the\n * first view here would show its lazy panel as a spinner that never\n * resolves, because an unmatched panel's chunk is never loaded.\n */\nexport function AppRootShell({ app, inflate = true }: { app: AppHandle; inflate?: boolean }) {\n\tuseInflate(app, inflate);\n\tconst views = Object.values(app.views);\n\n\tconst activeViewId = useRouterState({\n\t\tselect: (state): string | null => owningViewIdOf(state.matches, views),\n\t});\n\tconst activeView =\n\t\tactiveViewId === null\n\t\t\t? undefined\n\t\t\t: Object.values(app.views).find(view => view.id === activeViewId);\n\n\treturn (\n\t\t<>\n\t\t\t{activeView !== undefined ? <VkView view={activeView} /> : <NotFound />}\n\t\t\t<Modals entries={app.modals} />\n\t\t\t<Popouts entries={app.popouts} />\n\t\t</>\n\t);\n}\n\n/**\n * The active story key for Epic/Root shells: the view key the app author\n * wrote in `defineView(\"feed\")`, or null on a notFound navigation — render\n * the NotFound screen instead of a story. Pairs with `<VkView view={…}>`\n * whose nav defaults to the same key, so `activeStory === tab.id` compares\n * user-authored keys on both sides.\n */\nexport function useActiveStory(app: AppHandle): string | null {\n\tconst views = Object.values(app.views);\n\treturn useRouterState({\n\t\tselect: (state): string | null => {\n\t\t\tconst viewId = owningViewIdOf(state.matches, views);\n\t\t\treturn viewId === null ? null : storyKeyOf(viewId);\n\t\t},\n\t});\n}\n\n/**\n * Inflate (spike parity): a cold start (first history entry) on a panel\n * that is NOT the first of its view replays the panel flow before it, so\n * back walks the natural chain instead of closing the app. One-shot, waits\n * for the router to go idle, skips warm navigations. `AppRootShell` runs\n * it automatically; custom shells (Epic/Tabbar) call the hook themselves.\n */\nexport function useInflate(app: AppHandle, enabled = true): void {\n\tconst router = useRouter();\n\tconst snapshot = useRouterState({\n\t\tselect: state => {\n\t\t\tlet panelKey: string | null = null;\n\t\t\tlet panelPath: string | null = null;\n\t\t\tconst modalPaths: string[] = [];\n\t\t\tfor (let i = state.matches.length - 1; i >= 0; i--) {\n\t\t\t\tconst routeId = state.matches[i]?.routeId;\n\t\t\t\tif (routeId === undefined) {\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t\tconst route = looseRouteOf(router, routeId);\n\t\t\t\tconst staticData = route?.options.staticData;\n\t\t\t\tif (staticData?.modal !== undefined && route !== undefined) {\n\t\t\t\t\t// modal levels of the deep link, deepest first (reversed below)\n\t\t\t\t\tif (route.fullPath !== undefined) {\n\t\t\t\t\t\tmodalPaths.push(route.fullPath);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif (staticData?.panel !== undefined) {\n\t\t\t\t\tpanelKey = staticData.panel;\n\t\t\t\t\tpanelPath = route?.fullPath ?? null;\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn {\n\t\t\t\tstatus: state.status,\n\t\t\t\tindex:\n\t\t\t\t\t(state.location.state as { __TSR_index?: number } | undefined)?.__TSR_index ??\n\t\t\t\t\t0,\n\t\t\t\thref: state.location.href,\n\t\t\t\tpanelKey,\n\t\t\t\tpanelPath,\n\t\t\t\tmodalPaths: modalPaths.toReversed(),\n\t\t\t\tdeepestParams:\n\t\t\t\t\t(\n\t\t\t\t\t\tstate.matches[state.matches.length - 1] as\n\t\t\t\t\t\t\t| { params?: Record<string, string> }\n\t\t\t\t\t\t\t| undefined\n\t\t\t\t\t)?.params ?? {},\n\t\t\t};\n\t\t},\n\t});\n\tconst done = useRef(false);\n\n\tuseEffect(() => {\n\t\tif (done.current || !enabled || snapshot.status !== \"idle\") {\n\t\t\treturn; // one-shot; retry until the router settles\n\t\t}\n\t\tdone.current = true;\n\t\tif (snapshot.index > 0 || snapshot.panelKey === null) {\n\t\t\treturn; // warm navigation or an unknown panel — nothing to inflate\n\t\t}\n\t\tconst target = app.panels[snapshot.panelKey];\n\t\tif (target === undefined) {\n\t\t\treturn;\n\t\t}\n\t\tconst ordered = Object.values(app.panels).filter(panel => panel.viewId === target.viewId);\n\t\tlet chain = deriveInflateChain(ordered, target.routeId, snapshot.deepestParams);\n\t\t// a deep link INTO A ROUTE MODAL: the full ancestor chain must sit in\n\t\t// history below the target — the parent panel, then every modal level\n\t\t// (outer → inner) — or back skips the screens the user is logically\n\t\t// on. Only modal targets: for a panel target the parentHref IS the\n\t\t// current location and would replay a pointless no-op navigation\n\t\tif (snapshot.modalPaths.length > 0 && snapshot.panelPath !== null) {\n\t\t\tconst parentHref = interpolatePath(snapshot.panelPath, snapshot.deepestParams);\n\t\t\tif (parentHref !== null && chain[chain.length - 1] !== parentHref) {\n\t\t\t\tchain = [...chain, parentHref];\n\t\t\t}\n\t\t}\n\t\tfor (const modalPath of snapshot.modalPaths) {\n\t\t\tconst modalHref = interpolatePath(modalPath, snapshot.deepestParams);\n\t\t\tif (modalHref !== null && chain[chain.length - 1] !== modalHref) {\n\t\t\t\tchain = [...chain, modalHref];\n\t\t\t}\n\t\t}\n\t\tif (chain.length === 0) {\n\t\t\treturn;\n\t\t}\n\t\tconst { href: targetHref } = snapshot;\n\t\tvoid (async () => {\n\t\t\tawait router.navigate({ href: chain[0], replace: true });\n\t\t\tfor (let i = 1; i < chain.length; i++) {\n\t\t\t\tawait router.navigate({ href: chain[i] });\n\t\t\t}\n\t\t\tawait router.navigate({ href: targetHref });\n\t\t})();\n\t});\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"app.js","names":[],"sources":["../../src/preset/app.tsx"],"sourcesContent":["import { useRouter, useRouterState } from \"@tanstack/react-router\";\nimport { useEffect, useRef } from \"react\";\nimport { looseRouteOf } from \"../core/loose-route.ts\";\nimport { Modals } from \"../vkui/modals.tsx\";\nimport { NotFound } from \"../vkui/not-found.tsx\";\nimport { Popouts } from \"../vkui/popouts.tsx\";\nimport { storyKeyOf, VkView } from \"../vkui/vk-view.tsx\";\nimport type { AppHandle } from \"./builders.tsx\";\nimport { deriveInflateChain, interpolatePath } from \"./inflate.ts\";\n\ninterface MatchLike {\n\trouteId?: string;\n\tstatus?: string;\n\t_notFound?: boolean;\n}\n\n/**\n * The view route owning the deepest matched route, or null. The walk starts\n * at the deepest match because the chain depth varies — a modal is a masked\n * CHILD route of its panel, so `/view:feed/feed/comments` must still resolve\n * to the feed view. A notFound match anywhere in the chain wins over\n * everything: the shell must show the NotFound screen, never a story\n * fallback (an unmatched lazy panel's chunk never loads — a fallback story\n * would spin forever).\n */\nexport function owningViewIdOf(\n\tmatches: readonly MatchLike[],\n\tviews: readonly { id: string }[],\n): string | null {\n\tconst notFound = matches.some(\n\t\tmatch =>\n\t\t\tmatch.status === \"notFound\" || (match as { _notFound?: boolean })._notFound === true,\n\t);\n\tif (notFound) {\n\t\treturn null;\n\t}\n\tfor (let i = matches.length - 1; i >= 0; i--) {\n\t\tconst routeId = matches[i]?.routeId ?? \"\";\n\t\tconst view = views.find(view => routeId === view.id || routeId.startsWith(`${view.id}/`));\n\t\tif (view !== undefined) {\n\t\t\treturn view.id;\n\t\t}\n\t}\n\treturn null;\n}\n\n/**\n * Default root shell: the active view + the modal/popout hosts + inflate.\n * The active view is the one containing the deepest matched route\n * (single-view apps always get their only view). Multi-story apps with\n * Epic/Root are built with a custom `root` shell out of the same pieces.\n *\n * An unmatched URL (global 404 or a `notFound()` thrown by `beforeLoad`)\n * renders the VKUI `NotFound` instead of a view — falling back to the\n * first view here would show its lazy panel as a spinner that never\n * resolves, because an unmatched panel's chunk is never loaded.\n */\nexport function AppRootShell({ app, inflate = true }: { app: AppHandle; inflate?: boolean }) {\n\tuseInflate(app, inflate);\n\tconst views = Object.values(app.views);\n\n\tconst activeViewId = useRouterState({\n\t\tselect: (state): string | null => owningViewIdOf(state.matches, views),\n\t});\n\tconst activeView =\n\t\tactiveViewId === null\n\t\t\t? undefined\n\t\t\t: Object.values(app.views).find(view => view.id === activeViewId);\n\n\treturn (\n\t\t<>\n\t\t\t{activeView !== undefined ? <VkView view={activeView} /> : <NotFound />}\n\t\t\t<Modals entries={app.modals} />\n\t\t\t<Popouts entries={app.popouts} />\n\t\t</>\n\t);\n}\n\n/**\n * The active story key for Epic/Root shells: the view key the app author\n * wrote in `defineView(\"feed\")`, or null on a notFound navigation — render\n * the NotFound screen instead of a story. Pairs with `<VkView view={…}>`\n * whose nav defaults to the same key, so `activeStory === tab.id` compares\n * user-authored keys on both sides.\n */\nexport function useActiveStory(app: AppHandle): string | null {\n\tconst views = Object.values(app.views);\n\treturn useRouterState({\n\t\tselect: (state): string | null => {\n\t\t\tconst viewId = owningViewIdOf(state.matches, views);\n\t\t\treturn viewId === null ? null : storyKeyOf(viewId);\n\t\t},\n\t});\n}\n\n/**\n * Inflate (spike parity): a cold start (first history entry) on a panel\n * that is NOT the first of its view replays the panel flow before it, so\n * back walks the natural chain instead of closing the app. One-shot, waits\n * for the router to go idle, skips warm navigations. `AppRootShell` runs\n * it automatically; custom shells (Epic/Tabbar) call the hook themselves.\n */\nexport function useInflate(app: AppHandle, enabled = true): void {\n\tconst router = useRouter();\n\tconst snapshot = useRouterState({\n\t\tselect: state => {\n\t\t\tlet panelKey: string | null = null;\n\t\t\tlet panelPath: string | null = null;\n\t\t\tconst modalPaths: string[] = [];\n\t\t\tfor (let i = state.matches.length - 1; i >= 0; i--) {\n\t\t\t\tconst routeId = state.matches[i]?.routeId;\n\t\t\t\tif (routeId === undefined) {\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t\tconst route = looseRouteOf(router, routeId);\n\t\t\t\tconst staticData = route?.options.staticData;\n\t\t\t\tif (staticData?.modal !== undefined && route !== undefined) {\n\t\t\t\t\t// modal levels of the deep link, deepest first (reversed below)\n\t\t\t\t\tif (route.fullPath !== undefined) {\n\t\t\t\t\t\tmodalPaths.push(route.fullPath);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif (staticData?.panel !== undefined) {\n\t\t\t\t\tpanelKey = staticData.panel;\n\t\t\t\t\tpanelPath = route?.fullPath ?? null;\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn {\n\t\t\t\tstatus: state.status,\n\t\t\t\tindex:\n\t\t\t\t\t(state.location.state as { __TSR_index?: number } | undefined)?.__TSR_index ??\n\t\t\t\t\t0,\n\t\t\t\thref: state.location.href,\n\t\t\t\tpanelKey,\n\t\t\t\tpanelPath,\n\t\t\t\tmodalPaths: modalPaths.toReversed(),\n\t\t\t\tdeepestParams:\n\t\t\t\t\t(\n\t\t\t\t\t\tstate.matches[state.matches.length - 1] as\n\t\t\t\t\t\t\t| { params?: Record<string, string> }\n\t\t\t\t\t\t\t| undefined\n\t\t\t\t\t)?.params ?? {},\n\t\t\t};\n\t\t},\n\t});\n\tconst done = useRef(false);\n\n\tuseEffect(() => {\n\t\tif (done.current || !enabled || snapshot.status !== \"idle\") {\n\t\t\treturn; // one-shot; retry until the router settles\n\t\t}\n\t\tdone.current = true;\n\t\tif (snapshot.index > 0 || snapshot.panelKey === null) {\n\t\t\treturn; // warm navigation or an unknown panel — nothing to inflate\n\t\t}\n\t\tconst target = app.panels[snapshot.panelKey];\n\t\tif (target === undefined) {\n\t\t\treturn;\n\t\t}\n\t\tconst ordered = Object.values(app.panels).filter(panel => panel.viewId === target.viewId);\n\t\tlet chain = deriveInflateChain(ordered, target.routeId, snapshot.deepestParams);\n\t\t// a deep link INTO A ROUTE MODAL: the full ancestor chain must sit in\n\t\t// history below the target — the parent panel, then every modal level\n\t\t// (outer → inner) — or back skips the screens the user is logically\n\t\t// on. Only modal targets: for a panel target the parentHref IS the\n\t\t// current location and would replay a pointless no-op navigation\n\t\tif (snapshot.modalPaths.length > 0 && snapshot.panelPath !== null) {\n\t\t\tconst parentHref = interpolatePath(snapshot.panelPath, snapshot.deepestParams);\n\t\t\tif (parentHref !== null && chain[chain.length - 1] !== parentHref) {\n\t\t\t\tchain = [...chain, parentHref];\n\t\t\t}\n\t\t}\n\t\tfor (const modalPath of snapshot.modalPaths) {\n\t\t\tconst modalHref = interpolatePath(modalPath, snapshot.deepestParams);\n\t\t\tif (modalHref !== null && chain[chain.length - 1] !== modalHref) {\n\t\t\t\tchain = [...chain, modalHref];\n\t\t\t}\n\t\t}\n\t\tif (chain.length === 0) {\n\t\t\treturn;\n\t\t}\n\t\tconst { href: targetHref } = snapshot;\n\t\tvoid (async () => {\n\t\t\tawait router.navigate({ href: chain[0], replace: true });\n\t\t\tfor (let i = 1; i < chain.length; i++) {\n\t\t\t\tawait router.navigate({ href: chain[i] });\n\t\t\t}\n\t\t\tawait router.navigate({ href: targetHref });\n\t\t})();\n\t});\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;AAyBA,SAAgB,eACf,SACA,OACgB;CAKhB,IAJiB,QAAQ,MACxB,UACC,MAAM,WAAW,cAAe,MAAkC,cAAc,IAEvE,GACV,OAAO;CAER,KAAK,IAAI,IAAI,QAAQ,SAAS,GAAG,KAAK,GAAG,KAAK;EAC7C,MAAM,UAAU,QAAQ,EAAE,EAAE,WAAW;EACvC,MAAM,OAAO,MAAM,MAAK,SAAQ,YAAY,KAAK,MAAM,QAAQ,WAAW,GAAG,KAAK,GAAG,EAAE,CAAC;EACxF,IAAI,SAAS,KAAA,GACZ,OAAO,KAAK;CAEd;CACA,OAAO;AACR;;;;;;;;;;;;AAaA,SAAgB,aAAa,EAAE,KAAK,UAAU,QAA+C;CAC5F,WAAW,KAAK,OAAO;CACvB,MAAM,QAAQ,OAAO,OAAO,IAAI,KAAK;CAErC,MAAM,eAAe,eAAe,EACnC,SAAS,UAAyB,eAAe,MAAM,SAAS,KAAK,EACtE,CAAC;CACD,MAAM,aACL,iBAAiB,OACd,KAAA,IACA,OAAO,OAAO,IAAI,KAAK,CAAC,CAAC,MAAK,SAAQ,KAAK,OAAO,YAAY;CAElE,OACC,qBAAA,UAAA,EAAA,UAAA;EACE,eAAe,KAAA,IAAY,oBAAC,QAAD,EAAQ,MAAM,WAAa,CAAA,IAAI,oBAAC,UAAD,CAAW,CAAA;EACtE,oBAAC,QAAD,EAAQ,SAAS,IAAI,OAAS,CAAA;EAC9B,oBAAC,SAAD,EAAS,SAAS,IAAI,QAAU,CAAA;CAC/B,EAAA,CAAA;AAEJ;;;;;;;;AASA,SAAgB,eAAe,KAA+B;CAC7D,MAAM,QAAQ,OAAO,OAAO,IAAI,KAAK;CACrC,OAAO,eAAe,EACrB,SAAS,UAAyB;EACjC,MAAM,SAAS,eAAe,MAAM,SAAS,KAAK;EAClD,OAAO,WAAW,OAAO,OAAO,WAAW,MAAM;CAClD,EACD,CAAC;AACF;;;;;;;;AASA,SAAgB,WAAW,KAAgB,UAAU,MAAY;CAChE,MAAM,SAAS,UAAU;CACzB,MAAM,WAAW,eAAe,EAC/B,SAAQ,UAAS;EAChB,IAAI,WAA0B;EAC9B,IAAI,YAA2B;EAC/B,MAAM,aAAuB,CAAC;EAC9B,KAAK,IAAI,IAAI,MAAM,QAAQ,SAAS,GAAG,KAAK,GAAG,KAAK;GACnD,MAAM,UAAU,MAAM,QAAQ,EAAE,EAAE;GAClC,IAAI,YAAY,KAAA,GACf;GAED,MAAM,QAAQ,aAAa,QAAQ,OAAO;GAC1C,MAAM,aAAa,OAAO,QAAQ;GAClC,IAAI,YAAY,UAAU,KAAA,KAAa,UAAU,KAAA,GAE5C;QAAA,MAAM,aAAa,KAAA,GACtB,WAAW,KAAK,MAAM,QAAQ;GAAA;GAGhC,IAAI,YAAY,UAAU,KAAA,GAAW;IACpC,WAAW,WAAW;IACtB,YAAY,OAAO,YAAY;IAC/B;GACD;EACD;EACA,OAAO;GACN,QAAQ,MAAM;GACd,OACE,MAAM,SAAS,OAAgD,eAChE;GACD,MAAM,MAAM,SAAS;GACrB;GACA;GACA,YAAY,WAAW,WAAW;GAClC,eAEE,MAAM,QAAQ,MAAM,QAAQ,SAAS,EAAE,EAGrC,UAAU,CAAC;EAChB;CACD,EACD,CAAC;CACD,MAAM,OAAO,OAAO,KAAK;CAEzB,gBAAgB;EACf,IAAI,KAAK,WAAW,CAAC,WAAW,SAAS,WAAW,QACnD;EAED,KAAK,UAAU;EACf,IAAI,SAAS,QAAQ,KAAK,SAAS,aAAa,MAC/C;EAED,MAAM,SAAS,IAAI,OAAO,SAAS;EACnC,IAAI,WAAW,KAAA,GACd;EAED,MAAM,UAAU,OAAO,OAAO,IAAI,MAAM,CAAC,CAAC,QAAO,UAAS,MAAM,WAAW,OAAO,MAAM;EACxF,IAAI,QAAQ,mBAAmB,SAAS,OAAO,SAAS,SAAS,aAAa;EAM9E,IAAI,SAAS,WAAW,SAAS,KAAK,SAAS,cAAc,MAAM;GAClE,MAAM,aAAa,gBAAgB,SAAS,WAAW,SAAS,aAAa;GAC7E,IAAI,eAAe,QAAQ,MAAM,MAAM,SAAS,OAAO,YACtD,QAAQ,CAAC,GAAG,OAAO,UAAU;EAE/B;EACA,KAAK,MAAM,aAAa,SAAS,YAAY;GAC5C,MAAM,YAAY,gBAAgB,WAAW,SAAS,aAAa;GACnE,IAAI,cAAc,QAAQ,MAAM,MAAM,SAAS,OAAO,WACrD,QAAQ,CAAC,GAAG,OAAO,SAAS;EAE9B;EACA,IAAI,MAAM,WAAW,GACpB;EAED,MAAM,EAAE,MAAM,eAAe;EAC7B,CAAM,YAAY;GACjB,MAAM,OAAO,SAAS;IAAE,MAAM,MAAM;IAAI,SAAS;GAAK,CAAC;GACvD,KAAK,IAAI,IAAI,GAAG,IAAI,MAAM,QAAQ,KACjC,MAAM,OAAO,SAAS,EAAE,MAAM,MAAM,GAAG,CAAC;GAEzC,MAAM,OAAO,SAAS,EAAE,MAAM,WAAW,CAAC;EAC3C,EAAA,CAAG;CACJ,CAAC;AACF"}
@@ -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 { VkNavigator, useNavigator } from "./preset/navigator.js";
13
14
  import { 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, 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
@@ -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, 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 };
@@ -1,6 +1,13 @@
1
1
  import { AnyRoute } from "@tanstack/react-router";
2
2
  //#region src/vkui/vk-view.d.ts
3
3
  export declare function panelIdOf(route: AnyRoute): string;
4
+ /**
5
+ * The VKUI nav key of a view route: "/view:feed" → "feed". Epic/Root match
6
+ * children by getNavId, so the raw route id would leak the route-tree
7
+ * convention into the UI layer. This is also the key the app author wrote
8
+ * in `defineView("feed")`.
9
+ */
10
+ export declare function storyKeyOf(routeId: unknown): string;
4
11
  export interface VkViewProps {
5
12
  /** The pathless view layout route created by the preset. */
6
13
  view: AnyRoute;
@@ -16,7 +23,9 @@ export interface VkViewProps {
16
23
  * the animation direction from the children order — a plain <Outlet>
17
24
  * breaks both. A lazy panel's `options.component` is undefined until its
18
25
  * chunk resolves (TanStack merges loaded options in place), so the panel
19
- * renders a spinner fallback until then. For the same reason TanStack's
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
20
29
  * Outlet-driven `pendingComponent` swap never happens on its own — while
21
30
  * the panel's match is pending (loader/beforeLoad), the route's
22
31
  * `pendingComponent` renders instead of the component.
@@ -1 +1 @@
1
- {"version":3,"file":"vk-view.d.ts","names":[],"sources":["../../src/vkui/vk-view.tsx"],"mappings":";;wBAUgB,UAAU,OAAO;iBAoChB;;EAEhB,MAAM;;EAEN;;EAEA;;;;;;;;;;;;;;wBAee,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"}
@@ -1,7 +1,8 @@
1
1
  import { usePanelsHistory } from "../react/hooks.js";
2
2
  import { useRouterState } from "@tanstack/react-router";
3
- import { Panel, Spinner, View } from "@vkontakte/vkui";
4
- import { jsx } from "react/jsx-runtime";
3
+ import { useEffect, useState } from "react";
4
+ import { Panel, PanelHeader, PanelSpinner, View } from "@vkontakte/vkui";
5
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
5
6
  //#region src/vkui/vk-view.tsx
6
7
  function panelIdOf(route) {
7
8
  return route.options.staticData?.panel ?? "";
@@ -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. For the same reason TanStack's
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 pendingRouteIds = useRouterState({ select: (state) => state.matches.filter((match) => match.status === "pending").map((match) => String(match.routeId)) });
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,23 +60,55 @@ 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 = pendingRouteIds.includes(String(child.id));
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(PanelSpinnerFallback, {});
70
+ else content = null;
62
71
  return /* @__PURE__ */ jsx(Panel, {
63
72
  nav: id,
64
- children: isPending ? Pending !== void 0 ? /* @__PURE__ */ jsx(Pending, {}) : /* @__PURE__ */ jsx(PanelSpinnerFallback, {}) : isNotFound ? /* @__PURE__ */ jsx(PanelSpinnerFallback, {}) : Component ? /* @__PURE__ */ jsx(Component, {}) : /* @__PURE__ */ jsx(PanelSpinnerFallback, {})
73
+ children: content
65
74
  }, id);
66
75
  })
67
76
  });
68
77
  }
69
78
  function PanelSpinnerFallback() {
70
- return /* @__PURE__ */ jsx("div", {
71
- style: {
72
- paddingTop: 64,
73
- textAlign: "center"
74
- },
75
- children: /* @__PURE__ */ jsx(Spinner, { size: "l" })
76
- });
79
+ return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(PanelHeader, {}), /* @__PURE__ */ jsx(PanelSpinner, { size: "l" })] });
80
+ }
81
+ /**
82
+ * The delayed-spinner gate: mirrors `pendingIds` but only after they have
83
+ * stayed pending for `delayMs` continuously; clears the moment the pending
84
+ * set empties. `pendingRouteIds` comes from a structurally-stable
85
+ * `useRouterState` select — the reference only changes when the pending set
86
+ * actually changes, so the timer legitimately resets per navigation.
87
+ */
88
+ /**
89
+ * The delayed-spinner gate: mirrors `pendingIds` but only after they have
90
+ * stayed pending for `delayMs` continuously; clears the moment the pending
91
+ * set empties. The state is a STRING KEY, not the array — array-typed state
92
+ * set from an effect re-triggers renders (a fresh reference every time)
93
+ * and loops; a key string with the React bail-out (same value → no
94
+ * re-render) cannot. `key` is stable across re-renders as long as the
95
+ * pending SET is the same, even if TanStack hands out a fresh array
96
+ * reference per render.
97
+ */
98
+ function useStalledIds(pendingIds, delayMs) {
99
+ const key = pendingIds.join("|");
100
+ const [stalledKey, setStalledKey] = useState("");
101
+ useEffect(() => {
102
+ if (key === "") {
103
+ setStalledKey((prev) => prev === "" ? prev : "");
104
+ return;
105
+ }
106
+ const timer = setTimeout(() => setStalledKey(key), delayMs);
107
+ return () => {
108
+ clearTimeout(timer);
109
+ };
110
+ }, [key, delayMs]);
111
+ return stalledKey === key && key !== "" ? pendingIds : [];
77
112
  }
78
113
  //#endregion
79
114
  export { VkView, panelIdOf, storyKeyOf };
@@ -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, Spinner, 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\treturn (\n\t\t<div style={{ paddingTop: 64, textAlign: \"center\" }}>\n\t\t\t<Spinner size=\"l\" />\n\t\t</div>\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;CAC/B,OACC,oBAAC,OAAD;EAAK,OAAO;GAAE,YAAY;GAAI,WAAW;EAAS;EACjD,UAAA,oBAAC,SAAD,EAAS,MAAK,IAAK,CAAA;CACf,CAAA;AAEP"}
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":"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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 } from "./vkui/vk-view.js";
3
+ import { 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, useHidePopout, usePopoutState, useShowPopout };
5
+ export { 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 } from "./vkui/vk-view.js";
1
+ import { 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, useHidePopout, usePopoutState, useShowPopout };
5
+ export { 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.1",
3
+ "version": "0.1.3",
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",
@@ -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,
@@ -1,7 +1,7 @@
1
1
  import type { AnyRoute } from "@tanstack/react-router";
2
2
  import { useRouterState } from "@tanstack/react-router";
3
- import { Panel, Spinner, View } from "@vkontakte/vkui";
4
- import { type ComponentType } from "react";
3
+ import { Panel, PanelHeader, PanelSpinner, View } from "@vkontakte/vkui";
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. For the same reason TanStack's
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,46 @@ 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
- const isPending = pendingRouteIds.includes(String(child.id));
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
- // pending without a pendingComponent renders the FALLBACK, never
115
- // the component: a lazy chunk may have already merged its
116
- // component while the match is still pending (or parked on a
117
- // notFound boundary up the tree) — the component's data guards
118
- // assume a resolvable match and would throw in a hidden panel
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 = <PanelSpinnerFallback />;
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;
157
+ }
119
158
  return (
120
159
  <Panel key={id} nav={id}>
121
- {isPending ? (
122
- Pending !== undefined ? (
123
- <Pending />
124
- ) : (
125
- <PanelSpinnerFallback />
126
- )
127
- ) : isNotFound ? (
128
- <PanelSpinnerFallback />
129
- ) : Component ? (
130
- <Component />
131
- ) : (
132
- <PanelSpinnerFallback />
133
- )}
160
+ {content}
134
161
  </Panel>
135
162
  );
136
163
  })}
@@ -139,9 +166,46 @@ export function VkView({ view, nav, onSwipeBack }: VkViewProps) {
139
166
  }
140
167
 
141
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).
142
172
  return (
143
- <div style={{ paddingTop: 64, textAlign: "center" }}>
144
- <Spinner size="l" />
145
- </div>
173
+ <>
174
+ <PanelHeader />
175
+ <PanelSpinner size="l" />
176
+ </>
146
177
  );
147
178
  }
179
+
180
+ /**
181
+ * The delayed-spinner gate: mirrors `pendingIds` but only after they have
182
+ * stayed pending for `delayMs` continuously; clears the moment the pending
183
+ * set empties. `pendingRouteIds` comes from a structurally-stable
184
+ * `useRouterState` select — the reference only changes when the pending set
185
+ * actually changes, so the timer legitimately resets per navigation.
186
+ */
187
+ /**
188
+ * The delayed-spinner gate: mirrors `pendingIds` but only after they have
189
+ * stayed pending for `delayMs` continuously; clears the moment the pending
190
+ * set empties. The state is a STRING KEY, not the array — array-typed state
191
+ * set from an effect re-triggers renders (a fresh reference every time)
192
+ * and loops; a key string with the React bail-out (same value → no
193
+ * re-render) cannot. `key` is stable across re-renders as long as the
194
+ * pending SET is the same, even if TanStack hands out a fresh array
195
+ * reference per render.
196
+ */
197
+ function useStalledIds(pendingIds: string[], delayMs: number): string[] {
198
+ const key = pendingIds.join("|");
199
+ const [stalledKey, setStalledKey] = useState("");
200
+ useEffect(() => {
201
+ if (key === "") {
202
+ setStalledKey(prev => (prev === "" ? prev : ""));
203
+ return;
204
+ }
205
+ const timer = setTimeout(() => setStalledKey(key), delayMs);
206
+ return () => {
207
+ clearTimeout(timer);
208
+ };
209
+ }, [key, delayMs]);
210
+ return stalledKey === key && key !== "" ? pendingIds : [];
211
+ }
package/src/vkui.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { VkView, panelIdOf, type VkViewProps } from "./vkui/vk-view.tsx";
1
+ export { VkView, panelIdOf, storyKeyOf, type VkViewProps } from "./vkui/vk-view.tsx";
2
2
  export {
3
3
  Modals,
4
4
  ModalPageSurface,