From 61231cec4f977d0b9c0ba1b3d0b6fb6497c20844 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 26 Jul 2026 08:05:07 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Sparkling=20Router=20foundation=20?= =?UTF-8?q?=E2=80=94=20stack=20protocol,=20CompositeHistory,=20codegen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit URL-first dual-layer navigation scaffold from the Sparkling Router RFC: - sparkling-navigation: router.stack.* JS API + iOS StackRouterService stubs - sparkling-router: CompositeHistory, GlobalStackMirror, scheme/manifest helpers - sparkling-router-plugin: _container boundary scan → multi-entry + manifest - sparkling-sdk iOS: SPKContainerRegistry, SPKStackCoordinator, present fallback - examples/router-demo: S0 soft-nav demo + container-boundary authoring sample S3′ decision: keep _container*.tsx markers; TanStack ignores via routeFileIgnorePattern. Android stack host interface stubbed for P3. Co-authored-by: Xuan Huang (黄玄) --- examples/router-demo/.gitignore | 3 + examples/router-demo/README.md | 32 + examples/router-demo/lynx.config.ts | 56 ++ examples/router-demo/package.json | 41 + examples/router-demo/scripts/codegen.mjs | 12 + examples/router-demo/scripts/codegen.test.mjs | 24 + examples/router-demo/src/entries/main.tsx | 52 ++ examples/router-demo/src/react-lynx-shim.ts | 18 + .../router-demo/src/route-manifest.gen.json | 38 + examples/router-demo/src/routeTree.gen.ts | 113 +++ examples/router-demo/src/routes/__root.tsx | 12 + .../router-demo/src/routes/feed/$postId.tsx | 23 + .../src/routes/feed/_container.tsx | 12 + .../router-demo/src/routes/feed/index.tsx | 27 + examples/router-demo/src/routes/index.tsx | 22 + .../src/routes/settings/_container.modal.tsx | 8 + .../router-demo/src/routes/settings/index.tsx | 13 + examples/router-demo/src/styles.css | 48 + examples/router-demo/tsconfig.json | 13 + .../router/utils/IHostStackRouterDepend.kt | 63 ++ .../method/router/utils/RouterProvider.kt | 3 + .../methods/sparkling-navigation/index.ts | 1 + .../Core/Methods/Stack/StackMethods.swift | 168 ++++ .../Core/Methods/Stack/StackModels.swift | 156 ++++ .../Core/Protocols/StackRouterService.swift | 22 + .../sparkling-navigation/module.config.json | 93 +- .../__snapshots__/index.test.ts.snap | 21 +- .../src/__tests__/index.test.ts | 33 +- .../src/__tests__/stack/stack.test.ts | 105 +++ .../sparkling-navigation/src/stack/call.ts | 63 ++ .../sparkling-navigation/src/stack/events.ts | 35 + .../src/stack/getState.ts | 15 + .../sparkling-navigation/src/stack/index.ts | 28 + .../sparkling-navigation/src/stack/pop.ts | 12 + .../sparkling-navigation/src/stack/popTo.ts | 15 + .../src/stack/prefetch.ts | 29 + .../sparkling-navigation/src/stack/push.ts | 18 + .../sparkling-navigation/src/stack/replace.ts | 15 + .../sparkling-navigation/src/stack/reset.ts | 14 + .../src/stack/syncOwnLocation.ts | 25 + .../sparkling-navigation/src/stack/types.d.ts | 92 ++ packages/sparkling-router-plugin/README.md | 54 ++ packages/sparkling-router-plugin/index.ts | 22 + .../sparkling-router-plugin/jest.config.ts | 18 + packages/sparkling-router-plugin/package.json | 50 ++ packages/sparkling-router-plugin/rspack.ts | 6 + .../src/__tests__/scan.test.ts | 58 ++ .../sparkling-router-plugin/src/generate.ts | 99 +++ .../src/path-from-file.ts | 93 ++ .../sparkling-router-plugin/src/plugin.ts | 75 ++ packages/sparkling-router-plugin/src/scan.ts | 156 ++++ packages/sparkling-router-plugin/src/types.ts | 51 ++ .../sparkling-router-plugin/tsconfig.json | 23 + packages/sparkling-router/README.md | 54 ++ packages/sparkling-router/index.ts | 61 ++ packages/sparkling-router/jest.config.ts | 24 + packages/sparkling-router/package.json | 50 ++ .../src/__tests__/composite-history.test.ts | 113 +++ .../src/__tests__/global-stack-mirror.test.ts | 38 + .../src/__tests__/path-match.test.ts | 37 + .../src/__tests__/scheme.test.ts | 41 + .../sparkling-router/src/composite-history.ts | 216 +++++ .../sparkling-router/src/create-router.ts | 100 +++ .../src/global-stack-mirror.ts | 65 ++ .../sparkling-router/src/in-memory-stack.ts | 170 ++++ packages/sparkling-router/src/path-match.ts | 132 +++ .../sparkling-router/src/protocol-client.ts | 64 ++ packages/sparkling-router/src/scheme.ts | 92 ++ packages/sparkling-router/src/types.ts | 111 +++ packages/sparkling-router/tsconfig.json | 22 + .../Router/SPKContainerRegistry.swift | 59 ++ .../Container/Router/SPKRouter.swift | 8 +- .../Router/SPKStackCoordinator.swift | 126 +++ .../Container/SPKViewController.swift | 7 + .../Container/Utils/SPKEventDefines.swift | 5 + pnpm-lock.yaml | 838 +++++++++++++++--- 76 files changed, 4599 insertions(+), 132 deletions(-) create mode 100644 examples/router-demo/.gitignore create mode 100644 examples/router-demo/README.md create mode 100644 examples/router-demo/lynx.config.ts create mode 100644 examples/router-demo/package.json create mode 100644 examples/router-demo/scripts/codegen.mjs create mode 100644 examples/router-demo/scripts/codegen.test.mjs create mode 100644 examples/router-demo/src/entries/main.tsx create mode 100644 examples/router-demo/src/react-lynx-shim.ts create mode 100644 examples/router-demo/src/route-manifest.gen.json create mode 100644 examples/router-demo/src/routeTree.gen.ts create mode 100644 examples/router-demo/src/routes/__root.tsx create mode 100644 examples/router-demo/src/routes/feed/$postId.tsx create mode 100644 examples/router-demo/src/routes/feed/_container.tsx create mode 100644 examples/router-demo/src/routes/feed/index.tsx create mode 100644 examples/router-demo/src/routes/index.tsx create mode 100644 examples/router-demo/src/routes/settings/_container.modal.tsx create mode 100644 examples/router-demo/src/routes/settings/index.tsx create mode 100644 examples/router-demo/src/styles.css create mode 100644 examples/router-demo/tsconfig.json create mode 100644 packages/methods/sparkling-navigation/android/src/main/java/com/tiktok/sparkling/method/router/utils/IHostStackRouterDepend.kt create mode 100644 packages/methods/sparkling-navigation/ios/Sources/Core/Methods/Stack/StackMethods.swift create mode 100644 packages/methods/sparkling-navigation/ios/Sources/Core/Methods/Stack/StackModels.swift create mode 100644 packages/methods/sparkling-navigation/ios/Sources/Core/Protocols/StackRouterService.swift create mode 100644 packages/methods/sparkling-navigation/src/__tests__/stack/stack.test.ts create mode 100644 packages/methods/sparkling-navigation/src/stack/call.ts create mode 100644 packages/methods/sparkling-navigation/src/stack/events.ts create mode 100644 packages/methods/sparkling-navigation/src/stack/getState.ts create mode 100644 packages/methods/sparkling-navigation/src/stack/index.ts create mode 100644 packages/methods/sparkling-navigation/src/stack/pop.ts create mode 100644 packages/methods/sparkling-navigation/src/stack/popTo.ts create mode 100644 packages/methods/sparkling-navigation/src/stack/prefetch.ts create mode 100644 packages/methods/sparkling-navigation/src/stack/push.ts create mode 100644 packages/methods/sparkling-navigation/src/stack/replace.ts create mode 100644 packages/methods/sparkling-navigation/src/stack/reset.ts create mode 100644 packages/methods/sparkling-navigation/src/stack/syncOwnLocation.ts create mode 100644 packages/methods/sparkling-navigation/src/stack/types.d.ts create mode 100644 packages/sparkling-router-plugin/README.md create mode 100644 packages/sparkling-router-plugin/index.ts create mode 100644 packages/sparkling-router-plugin/jest.config.ts create mode 100644 packages/sparkling-router-plugin/package.json create mode 100644 packages/sparkling-router-plugin/rspack.ts create mode 100644 packages/sparkling-router-plugin/src/__tests__/scan.test.ts create mode 100644 packages/sparkling-router-plugin/src/generate.ts create mode 100644 packages/sparkling-router-plugin/src/path-from-file.ts create mode 100644 packages/sparkling-router-plugin/src/plugin.ts create mode 100644 packages/sparkling-router-plugin/src/scan.ts create mode 100644 packages/sparkling-router-plugin/src/types.ts create mode 100644 packages/sparkling-router-plugin/tsconfig.json create mode 100644 packages/sparkling-router/README.md create mode 100644 packages/sparkling-router/index.ts create mode 100644 packages/sparkling-router/jest.config.ts create mode 100644 packages/sparkling-router/package.json create mode 100644 packages/sparkling-router/src/__tests__/composite-history.test.ts create mode 100644 packages/sparkling-router/src/__tests__/global-stack-mirror.test.ts create mode 100644 packages/sparkling-router/src/__tests__/path-match.test.ts create mode 100644 packages/sparkling-router/src/__tests__/scheme.test.ts create mode 100644 packages/sparkling-router/src/composite-history.ts create mode 100644 packages/sparkling-router/src/create-router.ts create mode 100644 packages/sparkling-router/src/global-stack-mirror.ts create mode 100644 packages/sparkling-router/src/in-memory-stack.ts create mode 100644 packages/sparkling-router/src/path-match.ts create mode 100644 packages/sparkling-router/src/protocol-client.ts create mode 100644 packages/sparkling-router/src/scheme.ts create mode 100644 packages/sparkling-router/src/types.ts create mode 100644 packages/sparkling-router/tsconfig.json create mode 100644 packages/sparkling-sdk/ios/Sparkling/Sources/Application/Container/Router/SPKContainerRegistry.swift create mode 100644 packages/sparkling-sdk/ios/Sparkling/Sources/Application/Container/Router/SPKStackCoordinator.swift diff --git a/examples/router-demo/.gitignore b/examples/router-demo/.gitignore new file mode 100644 index 00000000..a98f9700 --- /dev/null +++ b/examples/router-demo/.gitignore @@ -0,0 +1,3 @@ +src/generated/ +src/rspeedy-entries.gen.json +dist/ diff --git a/examples/router-demo/README.md b/examples/router-demo/README.md new file mode 100644 index 00000000..c5ebeaa4 --- /dev/null +++ b/examples/router-demo/README.md @@ -0,0 +1,32 @@ +# router-demo + +Sparkling Router end-to-end sample. + +## What's covered + +| Milestone | Status in this example | +| --- | --- | +| **S0** soft nav (TanStack + memory history in one LynxView) | ✅ `src/entries/main.tsx` | +| **S3′** `_container` partition → manifest + entries | ✅ `sparkling-router-plugin` via `pnpm codegen` | +| **S4** native stack protocol | JS + iOS stubs in packages; host wiring next | + +## Authoring tree + +``` +src/routes/ + __root.tsx + index.tsx # "/" + feed/_container.tsx # hard boundary + feed/index.tsx # soft "/feed" + feed/$postId.tsx # soft "/feed/$postId" + settings/_container.modal.tsx # modal hard boundary + settings/index.tsx +``` + +## Scripts + +```bash +pnpm codegen # emit route-manifest.gen.json + container stubs +pnpm dev # rspeedy soft-nav demo +pnpm test # codegen smoke test +``` diff --git a/examples/router-demo/lynx.config.ts b/examples/router-demo/lynx.config.ts new file mode 100644 index 00000000..d68a408c --- /dev/null +++ b/examples/router-demo/lynx.config.ts @@ -0,0 +1,56 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { defineConfig } from '@lynx-js/rspeedy' +import { pluginQRCode } from '@lynx-js/qrcode-rsbuild-plugin' +import { pluginReactLynx } from '@lynx-js/react-rsbuild-plugin' +import { tanstackRouter } from '@tanstack/router-plugin/rspack' +import { sparklingRouter } from 'sparkling-router-plugin/rspack' + +const exampleName = path.basename(path.dirname(fileURLToPath(import.meta.url))) + +export default defineConfig({ + environments: { + lynx: {}, + web: {}, + }, + source: { + entry: { + // S0: single soft-nav container entry (home + in-container feed soft routes). + main: './src/entries/main.tsx', + }, + }, + resolve: { + alias: { + // Shim adds React.use placeholder required by @tanstack/react-router ≥1.17x + react$: path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + 'src/react-lynx-shim.ts', + ), + }, + }, + output: { + assetPrefix: `https://tiktok.github.io/sparkling/examples/${exampleName}/dist/`, + }, + tools: { + rspack: { + plugins: [ + sparklingRouter({ + routesDirectory: 'src/routes', + manifestOutput: 'src/route-manifest.gen.json', + containersOutput: 'src/generated/containers', + }), + tanstackRouter({ + target: 'react', + routesDirectory: './src/routes', + generatedRouteTree: './src/routeTree.gen.ts', + // Container markers are owned by sparkling-router-plugin, not TanStack. + routeFileIgnorePattern: '_container', + }), + ], + }, + }, + plugins: [pluginQRCode(), pluginReactLynx()], +}) diff --git a/examples/router-demo/package.json b/examples/router-demo/package.json new file mode 100644 index 00000000..378b2ea3 --- /dev/null +++ b/examples/router-demo/package.json @@ -0,0 +1,41 @@ +{ + "name": "@sparkling-example/router-demo", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Sparkling Router S0 soft-nav demo + container-boundary authoring sample", + "repository": { + "type": "git", + "url": "https://github.com/tiktok/sparkling", + "directory": "examples/router-demo" + }, + "scripts": { + "codegen": "node ./scripts/codegen.mjs", + "prebuild": "pnpm codegen", + "predev": "pnpm codegen", + "build": "rspeedy build", + "dev": "rspeedy dev", + "preview": "rspeedy preview", + "test": "node --test ./scripts/codegen.test.mjs" + }, + "dependencies": { + "@lynx-js/react": "^0.116.2", + "@tanstack/react-router": "1.170.18", + "sparkling-router": "workspace:*", + "url-search-params-polyfill": "^8.2.5" + }, + "devDependencies": { + "@lynx-js/qrcode-rsbuild-plugin": "^0.4.4", + "@lynx-js/react-rsbuild-plugin": "^0.12.7", + "@lynx-js/rspeedy": "^0.13.3", + "@lynx-js/types": "^3.7.0", + "@rsbuild/core": "1.7.2", + "@tanstack/router-plugin": "1.168.23", + "@types/react": "^18.3.20", + "sparkling-router-plugin": "workspace:*", + "typescript": "^5.8.3" + }, + "engines": { + "node": "^22 || ^24" + } +} diff --git a/examples/router-demo/scripts/codegen.mjs b/examples/router-demo/scripts/codegen.mjs new file mode 100644 index 00000000..3d146256 --- /dev/null +++ b/examples/router-demo/scripts/codegen.mjs @@ -0,0 +1,12 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { generateSparklingRoutes } from 'sparkling-router-plugin' + +const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const result = generateSparklingRoutes(projectRoot) +console.log( + `[router-demo] codegen: ${result.manifest.containers.length} containers → ${path.relative(projectRoot, result.manifestPath)}`, +) diff --git a/examples/router-demo/scripts/codegen.test.mjs b/examples/router-demo/scripts/codegen.test.mjs new file mode 100644 index 00000000..06d1c56a --- /dev/null +++ b/examples/router-demo/scripts/codegen.test.mjs @@ -0,0 +1,24 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' +import test from 'node:test' +import { fileURLToPath } from 'node:url' +import { generateSparklingRoutes } from 'sparkling-router-plugin' + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') + +test('router-demo routes produce a multi-container manifest', () => { + const result = generateSparklingRoutes(root, { + routesDirectory: 'src/routes', + manifestOutput: 'src/route-manifest.gen.json', + containersOutput: 'src/generated/containers', + }) + + assert.ok(result.manifest.containers.length >= 2) + const ids = result.manifest.containers.map((c) => c.bundle) + assert.ok(ids.some((b) => b.includes('feed'))) + assert.ok(fs.existsSync(result.manifestPath)) +}) diff --git a/examples/router-demo/src/entries/main.tsx b/examples/router-demo/src/entries/main.tsx new file mode 100644 index 00000000..bc246525 --- /dev/null +++ b/examples/router-demo/src/entries/main.tsx @@ -0,0 +1,52 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import 'url-search-params-polyfill' +import { root } from '@lynx-js/react' +import { + createRouter, + RouterProvider, +} from '@tanstack/react-router' +import { + createSparklingHistory, + resolveContainerIdentity, + type RouteManifest, +} from 'sparkling-router' +import { routeTree } from '../routeTree.gen' +import manifestJson from '../route-manifest.gen.json' +import '../styles.css' + +const manifest = manifestJson as RouteManifest + +// S0 runs the full route tree in one container with an in-memory stack. +// Cross-boundary navigations are exercised in unit tests / later native S4. +const runtime = createSparklingHistory({ + manifest, + container: { + bundle: 'main', + ownedRoutes: manifest.containers.flatMap((c) => c.routes.map((r) => r.path)), + presentation: 'push', + }, + memoryStack: true, + queryItems: + typeof lynx !== 'undefined' + ? (lynx as { __globalProps?: { queryItems?: Record } }).__globalProps + ?.queryItems + : undefined, +}) + +const router = createRouter({ + routeTree, + history: runtime.history, + isServer: false, +}) + +declare module '@tanstack/react-router' { + interface Register { + router: typeof router + } +} + +void resolveContainerIdentity + +root.render() diff --git a/examples/router-demo/src/react-lynx-shim.ts b/examples/router-demo/src/react-lynx-shim.ts new file mode 100644 index 00000000..7e185491 --- /dev/null +++ b/examples/router-demo/src/react-lynx-shim.ts @@ -0,0 +1,18 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +/** + * ReactLynx compat + shims required by newer @tanstack/react-router builds + * that touch `React.use` (React 19). Lynx compat does not export `use`; + * provide an undefined placeholder so ESM linking succeeds (runtime path + * guards with optional chaining / presence checks). + */ +export * from '@lynx-js/react/compat' +export { root } from '@lynx-js/react' + +// React 19 API stub — TanStack Router only reads it when available. +export const use = undefined as unknown as typeof import('react').use + +import * as Compat from '@lynx-js/react/compat' +export default Compat diff --git a/examples/router-demo/src/route-manifest.gen.json b/examples/router-demo/src/route-manifest.gen.json new file mode 100644 index 00000000..b7af15e5 --- /dev/null +++ b/examples/router-demo/src/route-manifest.gen.json @@ -0,0 +1,38 @@ +{ + "version": "1", + "scheme": { + "base": "hybrid://lynxview_page" + }, + "containers": [ + { + "bundle": "feed.lynx.bundle", + "presentation": "push", + "routes": [ + { + "path": "/feed/$postId" + }, + { + "path": "/feed" + } + ] + }, + { + "bundle": "main.lynx.bundle", + "presentation": "push", + "routes": [ + { + "path": "/" + } + ] + }, + { + "bundle": "settings.lynx.bundle", + "presentation": "modal", + "routes": [ + { + "path": "/settings" + } + ] + } + ] +} diff --git a/examples/router-demo/src/routeTree.gen.ts b/examples/router-demo/src/routeTree.gen.ts new file mode 100644 index 00000000..04c065a9 --- /dev/null +++ b/examples/router-demo/src/routeTree.gen.ts @@ -0,0 +1,113 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +import { Route as rootRouteImport } from './routes/__root' +import { Route as IndexRouteImport } from './routes/index' +import { Route as FeedIndexRouteImport } from './routes/feed/index' +import { Route as FeedPostIdRouteImport } from './routes/feed/$postId' +import { Route as SettingsIndexRouteImport } from './routes/settings/index' + +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) +const FeedIndexRoute = FeedIndexRouteImport.update({ + id: '/feed/', + path: '/feed/', + getParentRoute: () => rootRouteImport, +} as any) +const FeedPostIdRoute = FeedPostIdRouteImport.update({ + id: '/feed/$postId', + path: '/feed/$postId', + getParentRoute: () => rootRouteImport, +} as any) +const SettingsIndexRoute = SettingsIndexRouteImport.update({ + id: '/settings/', + path: '/settings/', + getParentRoute: () => rootRouteImport, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute + '/feed/$postId': typeof FeedPostIdRoute + '/feed/': typeof FeedIndexRoute + '/settings/': typeof SettingsIndexRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/feed/$postId': typeof FeedPostIdRoute + '/feed': typeof FeedIndexRoute + '/settings': typeof SettingsIndexRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/feed/$postId': typeof FeedPostIdRoute + '/feed/': typeof FeedIndexRoute + '/settings/': typeof SettingsIndexRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: '/' | '/feed/$postId' | '/feed/' | '/settings/' + fileRoutesByTo: FileRoutesByTo + to: '/' | '/feed/$postId' | '/feed' | '/settings' + id: '__root__' | '/' | '/feed/$postId' | '/feed/' | '/settings/' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + FeedPostIdRoute: typeof FeedPostIdRoute + FeedIndexRoute: typeof FeedIndexRoute + SettingsIndexRoute: typeof SettingsIndexRoute +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + '/feed/': { + id: '/feed/' + path: '/feed' + fullPath: '/feed/' + preLoaderRoute: typeof FeedIndexRouteImport + parentRoute: typeof rootRouteImport + } + '/feed/$postId': { + id: '/feed/$postId' + path: '/feed/$postId' + fullPath: '/feed/$postId' + preLoaderRoute: typeof FeedPostIdRouteImport + parentRoute: typeof rootRouteImport + } + '/settings/': { + id: '/settings/' + path: '/settings' + fullPath: '/settings/' + preLoaderRoute: typeof SettingsIndexRouteImport + parentRoute: typeof rootRouteImport + } + } +} + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + FeedPostIdRoute: FeedPostIdRoute, + FeedIndexRoute: FeedIndexRoute, + SettingsIndexRoute: SettingsIndexRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() diff --git a/examples/router-demo/src/routes/__root.tsx b/examples/router-demo/src/routes/__root.tsx new file mode 100644 index 00000000..82909d4e --- /dev/null +++ b/examples/router-demo/src/routes/__root.tsx @@ -0,0 +1,12 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { createRootRoute, Outlet } from '@tanstack/react-router' + +export const Route = createRootRoute({ + component: () => ( + + + + ), +}) diff --git a/examples/router-demo/src/routes/feed/$postId.tsx b/examples/router-demo/src/routes/feed/$postId.tsx new file mode 100644 index 00000000..1dc207d2 --- /dev/null +++ b/examples/router-demo/src/routes/feed/$postId.tsx @@ -0,0 +1,23 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { createFileRoute, Link } from '@tanstack/react-router' + +export const Route = createFileRoute('/feed/$postId')({ + component: PostPage, +}) + +function PostPage() { + const { postId } = Route.useParams() + return ( + + Post {postId} + Deep soft route — same LynxView / runtime + + + Back to feed + + + + ) +} diff --git a/examples/router-demo/src/routes/feed/_container.tsx b/examples/router-demo/src/routes/feed/_container.tsx new file mode 100644 index 00000000..aef2498b --- /dev/null +++ b/examples/router-demo/src/routes/feed/_container.tsx @@ -0,0 +1,12 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +/** + * Hard container boundary marker. + * In a multi-entry build this subtree becomes its own Lynx bundle. + * For the S0 soft-nav demo the whole tree still runs in `main`. + */ +export default { + presentation: 'push' as const, +} diff --git a/examples/router-demo/src/routes/feed/index.tsx b/examples/router-demo/src/routes/feed/index.tsx new file mode 100644 index 00000000..e2b0768f --- /dev/null +++ b/examples/router-demo/src/routes/feed/index.tsx @@ -0,0 +1,27 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { createFileRoute, Link } from '@tanstack/react-router' + +export const Route = createFileRoute('/feed/')({ + component: FeedPage, +}) + +function FeedPage() { + return ( + + Feed + Soft route inside the feed container boundary + + + Post 42 + + + + + Home + + + + ) +} diff --git a/examples/router-demo/src/routes/index.tsx b/examples/router-demo/src/routes/index.tsx new file mode 100644 index 00000000..9e045068 --- /dev/null +++ b/examples/router-demo/src/routes/index.tsx @@ -0,0 +1,22 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { createFileRoute, Link } from '@tanstack/react-router' + +export const Route = createFileRoute('/')({ + component: HomePage, +}) + +function HomePage() { + return ( + + Sparkling Router + S0 soft navigation inside one container + + + Open feed (soft) + + + + ) +} diff --git a/examples/router-demo/src/routes/settings/_container.modal.tsx b/examples/router-demo/src/routes/settings/_container.modal.tsx new file mode 100644 index 00000000..555a979e --- /dev/null +++ b/examples/router-demo/src/routes/settings/_container.modal.tsx @@ -0,0 +1,8 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +/** Modal container boundary — hard navigation target in multi-entry builds. */ +export default { + presentation: 'modal' as const, +} diff --git a/examples/router-demo/src/routes/settings/index.tsx b/examples/router-demo/src/routes/settings/index.tsx new file mode 100644 index 00000000..5f86c010 --- /dev/null +++ b/examples/router-demo/src/routes/settings/index.tsx @@ -0,0 +1,13 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/settings/')({ + component: () => ( + + Settings + Modal container (hard boundary) + + ), +}) diff --git a/examples/router-demo/src/styles.css b/examples/router-demo/src/styles.css new file mode 100644 index 00000000..ff047a5b --- /dev/null +++ b/examples/router-demo/src/styles.css @@ -0,0 +1,48 @@ +.root { + width: 100%; + height: 100%; + background: linear-gradient(165deg, #0b1f33 0%, #16324f 55%, #1f4d6d 100%); +} + +.page { + display: flex; + flex-direction: column; + padding: 48px 24px 24px; + width: 100%; + height: 100%; +} + +.title { + color: #f4f7fb; + font-size: 32px; + font-weight: 700; + margin-bottom: 8px; +} + +.subtitle { + color: #b7c7d8; + font-size: 16px; + margin-bottom: 28px; +} + +.btn { + background: #3dd6c6; + padding: 14px 18px; + margin-bottom: 12px; + border-radius: 8px; +} + +.btn-secondary { + background: transparent; + border: 1px solid #7f9db5; +} + +.btn-text { + color: #06212a; + font-size: 16px; + font-weight: 600; +} + +.btn-secondary .btn-text { + color: #d7e6f3; +} diff --git a/examples/router-demo/tsconfig.json b/examples/router-demo/tsconfig.json new file mode 100644 index 00000000..e0ac71c2 --- /dev/null +++ b/examples/router-demo/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "preserve", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "types": ["@lynx-js/types"] + }, + "include": ["src", "lynx.config.ts"] +} diff --git a/packages/methods/sparkling-navigation/android/src/main/java/com/tiktok/sparkling/method/router/utils/IHostStackRouterDepend.kt b/packages/methods/sparkling-navigation/android/src/main/java/com/tiktok/sparkling/method/router/utils/IHostStackRouterDepend.kt new file mode 100644 index 00000000..5012c60e --- /dev/null +++ b/packages/methods/sparkling-navigation/android/src/main/java/com/tiktok/sparkling/method/router/utils/IHostStackRouterDepend.kt @@ -0,0 +1,63 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +package com.tiktok.sparkling.method.router.utils + +import com.tiktok.sparkling.method.registry.core.IBridgeContext + +/** + * Optional host contract for the hard-navigation stack protocol (RFC §4). + * + * Android implements against the shared conformance suite after iOS freezes + * semantics. Default methods return false / empty so hosts can opt in gradually. + */ +interface IHostStackRouterDepend { + fun push( + bridgeContext: IBridgeContext?, + path: String, + search: Map?, + presentation: String?, + usePrefetched: Boolean, + animated: Boolean, + ): String? = null + + fun pop( + bridgeContext: IBridgeContext?, + result: Any?, + animated: Boolean, + ): String? = null + + fun popTo( + bridgeContext: IBridgeContext?, + entryId: String, + animated: Boolean, + ): Boolean = false + + fun replace( + bridgeContext: IBridgeContext?, + path: String, + search: Map?, + ): String? = null + + fun reset( + bridgeContext: IBridgeContext?, + entries: List>, + ): String? = null + + fun getState(bridgeContext: IBridgeContext?): Map = + mapOf("version" to 0, "entries" to emptyList()) + + fun prefetch( + bridgeContext: IBridgeContext?, + path: String, + search: Map?, + ): Boolean = false + + fun syncOwnLocation( + bridgeContext: IBridgeContext?, + path: String, + search: Map?, + ) { + // no-op by default + } +} diff --git a/packages/methods/sparkling-navigation/android/src/main/java/com/tiktok/sparkling/method/router/utils/RouterProvider.kt b/packages/methods/sparkling-navigation/android/src/main/java/com/tiktok/sparkling/method/router/utils/RouterProvider.kt index 9130f5d2..de8d6660 100644 --- a/packages/methods/sparkling-navigation/android/src/main/java/com/tiktok/sparkling/method/router/utils/RouterProvider.kt +++ b/packages/methods/sparkling-navigation/android/src/main/java/com/tiktok/sparkling/method/router/utils/RouterProvider.kt @@ -5,4 +5,7 @@ package com.tiktok.sparkling.method.router.utils object RouterProvider { var hostRouterDepend: IHostRouterDepend? = null + + /** Optional stack-protocol host. Null until Android S4/P3 adoption. */ + var hostStackRouterDepend: IHostStackRouterDepend? = null } diff --git a/packages/methods/sparkling-navigation/index.ts b/packages/methods/sparkling-navigation/index.ts index bf63b396..31382677 100644 --- a/packages/methods/sparkling-navigation/index.ts +++ b/packages/methods/sparkling-navigation/index.ts @@ -4,6 +4,7 @@ export * from './src/open/open'; export * from './src/close/close'; export * from './src/navigate/navigate'; +export * from './src/stack/index'; export type { OpenRequest, OpenResponse, OpenOptions } from './src/open/open.d'; export type { CloseRequest, CloseResponse } from './src/close/close.d'; export type { NavigateRequest, NavigateResponse, NavigateOptions } from './src/navigate/navigate.d'; diff --git a/packages/methods/sparkling-navigation/ios/Sources/Core/Methods/Stack/StackMethods.swift b/packages/methods/sparkling-navigation/ios/Sources/Core/Methods/Stack/StackMethods.swift new file mode 100644 index 00000000..a8b558e9 --- /dev/null +++ b/packages/methods/sparkling-navigation/ios/Sources/Core/Methods/Stack/StackMethods.swift @@ -0,0 +1,168 @@ +// Copyright 2026 The Sparkling Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import Foundation +import SparklingMethod + +private func resolveStackService() -> StackRouterService? { + DIProviderRegistry.provider.pipeShared().resolve(StackRouterService.self) +} + +private func dispatchStack( + completionHandler: PipeMethod.CompletionHandlerProtocol, + body: (StackRouterService, @escaping PipeMethod.CompletionBlock) -> Void +) { + guard let service = resolveStackService() else { + let status = MethodStatus.notImplemented(message: "StackRouterService is not registered") + completionHandler.handleCompletion(status: status, result: nil) + return + } + body(service) { status, result in + completionHandler.handleCompletion(status: status, result: result) + } +} + +@objc(StackPushMethod) +public class StackPushMethod: PipeMethod { + public override var methodName: String { "router.stack.push" } + public override class func methodName() -> String { "router.stack.push" } + @objc public override var paramsModelClass: AnyClass { StackPushParamModel.self } + @objc public override var resultModelClass: AnyClass { StackNavResultModel.self } + + @objc public override func call(withParamModel paramModel: Any, completionHandler: CompletionHandlerProtocol) { + guard let params = paramModel as? StackPushParamModel else { + completionHandler.handleCompletion(status: .invalidParameter(message: "Invalid parameter model type"), result: nil) + return + } + dispatchStack(completionHandler: completionHandler) { service, completion in + service.push(withParams: params, completion: completion) + } + } +} + +@objc(StackPopMethod) +public class StackPopMethod: PipeMethod { + public override var methodName: String { "router.stack.pop" } + public override class func methodName() -> String { "router.stack.pop" } + @objc public override var paramsModelClass: AnyClass { StackPopParamModel.self } + @objc public override var resultModelClass: AnyClass { StackNavResultModel.self } + + @objc public override func call(withParamModel paramModel: Any, completionHandler: CompletionHandlerProtocol) { + guard let params = paramModel as? StackPopParamModel else { + completionHandler.handleCompletion(status: .invalidParameter(message: "Invalid parameter model type"), result: nil) + return + } + dispatchStack(completionHandler: completionHandler) { service, completion in + service.pop(withParams: params, completion: completion) + } + } +} + +@objc(StackPopToMethod) +public class StackPopToMethod: PipeMethod { + public override var methodName: String { "router.stack.popTo" } + public override class func methodName() -> String { "router.stack.popTo" } + @objc public override var paramsModelClass: AnyClass { StackPopToParamModel.self } + @objc public override var resultModelClass: AnyClass { StackNavResultModel.self } + + @objc public override func call(withParamModel paramModel: Any, completionHandler: CompletionHandlerProtocol) { + guard let params = paramModel as? StackPopToParamModel else { + completionHandler.handleCompletion(status: .invalidParameter(message: "Invalid parameter model type"), result: nil) + return + } + dispatchStack(completionHandler: completionHandler) { service, completion in + service.popTo(withParams: params, completion: completion) + } + } +} + +@objc(StackReplaceMethod) +public class StackReplaceMethod: PipeMethod { + public override var methodName: String { "router.stack.replace" } + public override class func methodName() -> String { "router.stack.replace" } + @objc public override var paramsModelClass: AnyClass { StackReplaceParamModel.self } + @objc public override var resultModelClass: AnyClass { StackNavResultModel.self } + + @objc public override func call(withParamModel paramModel: Any, completionHandler: CompletionHandlerProtocol) { + guard let params = paramModel as? StackReplaceParamModel else { + completionHandler.handleCompletion(status: .invalidParameter(message: "Invalid parameter model type"), result: nil) + return + } + dispatchStack(completionHandler: completionHandler) { service, completion in + service.replace(withParams: params, completion: completion) + } + } +} + +@objc(StackResetMethod) +public class StackResetMethod: PipeMethod { + public override var methodName: String { "router.stack.reset" } + public override class func methodName() -> String { "router.stack.reset" } + @objc public override var paramsModelClass: AnyClass { StackResetParamModel.self } + @objc public override var resultModelClass: AnyClass { StackNavResultModel.self } + + @objc public override func call(withParamModel paramModel: Any, completionHandler: CompletionHandlerProtocol) { + guard let params = paramModel as? StackResetParamModel else { + completionHandler.handleCompletion(status: .invalidParameter(message: "Invalid parameter model type"), result: nil) + return + } + dispatchStack(completionHandler: completionHandler) { service, completion in + service.reset(withParams: params, completion: completion) + } + } +} + +@objc(StackGetStateMethod) +public class StackGetStateMethod: PipeMethod { + public override var methodName: String { "router.stack.getState" } + public override class func methodName() -> String { "router.stack.getState" } + @objc public override var paramsModelClass: AnyClass { StackGetStateParamModel.self } + @objc public override var resultModelClass: AnyClass { StackStateResultModel.self } + + @objc public override func call(withParamModel paramModel: Any, completionHandler: CompletionHandlerProtocol) { + guard let params = paramModel as? StackGetStateParamModel else { + completionHandler.handleCompletion(status: .invalidParameter(message: "Invalid parameter model type"), result: nil) + return + } + dispatchStack(completionHandler: completionHandler) { service, completion in + service.getState(withParams: params, completion: completion) + } + } +} + +@objc(StackPrefetchMethod) +public class StackPrefetchMethod: PipeMethod { + public override var methodName: String { "router.stack.prefetch" } + public override class func methodName() -> String { "router.stack.prefetch" } + @objc public override var paramsModelClass: AnyClass { StackPrefetchParamModel.self } + @objc public override var resultModelClass: AnyClass { EmptyMethodModelClass.self } + + @objc public override func call(withParamModel paramModel: Any, completionHandler: CompletionHandlerProtocol) { + guard let params = paramModel as? StackPrefetchParamModel else { + completionHandler.handleCompletion(status: .invalidParameter(message: "Invalid parameter model type"), result: nil) + return + } + dispatchStack(completionHandler: completionHandler) { service, completion in + service.prefetch(withParams: params, completion: completion) + } + } +} + +@objc(StackSyncOwnLocationMethod) +public class StackSyncOwnLocationMethod: PipeMethod { + public override var methodName: String { "router.stack.syncOwnLocation" } + public override class func methodName() -> String { "router.stack.syncOwnLocation" } + @objc public override var paramsModelClass: AnyClass { StackSyncOwnLocationParamModel.self } + @objc public override var resultModelClass: AnyClass { EmptyMethodModelClass.self } + + @objc public override func call(withParamModel paramModel: Any, completionHandler: CompletionHandlerProtocol) { + guard let params = paramModel as? StackSyncOwnLocationParamModel else { + completionHandler.handleCompletion(status: .invalidParameter(message: "Invalid parameter model type"), result: nil) + return + } + dispatchStack(completionHandler: completionHandler) { service, completion in + service.syncOwnLocation(withParams: params, completion: completion) + } + } +} diff --git a/packages/methods/sparkling-navigation/ios/Sources/Core/Methods/Stack/StackModels.swift b/packages/methods/sparkling-navigation/ios/Sources/Core/Methods/Stack/StackModels.swift new file mode 100644 index 00000000..c5fae577 --- /dev/null +++ b/packages/methods/sparkling-navigation/ios/Sources/Core/Methods/Stack/StackModels.swift @@ -0,0 +1,156 @@ +// Copyright 2026 The Sparkling Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import Foundation +import SparklingMethod + +@objc(StackNavResultModel) +public class StackNavResultModel: SPKMethodModel { + @objc public var entryId: String? + + public override class func jsonKeyPathsByPropertyKey() -> [AnyHashable: Any] { + return ["entryId": "entryId"] + } +} + +@objc(StackPushParamModel) +public class StackPushParamModel: SPKMethodModel { + @objc public var path: String? + @objc public var search: NSDictionary? + @objc public var presentation: String? + @objc public var usePrefetched: Bool = false + @objc public var animated: Bool = true + + public override class func requiredKeyPaths() -> Set? { + return ["path"] + } + + public override class func jsonKeyPathsByPropertyKey() -> [AnyHashable: Any] { + return [ + "path": "path", + "search": "search", + "presentation": "presentation", + "usePrefetched": "usePrefetched", + "animated": "animated", + ] + } +} + +@objc(StackPopParamModel) +public class StackPopParamModel: SPKMethodModel { + @objc public var result: Any? + @objc public var animated: Bool = true + + public override class func requiredKeyPaths() -> Set? { + return [] + } + + public override class func jsonKeyPathsByPropertyKey() -> [AnyHashable: Any] { + return [ + "result": "result", + "animated": "animated", + ] + } +} + +@objc(StackPopToParamModel) +public class StackPopToParamModel: SPKMethodModel { + @objc public var entryId: String? + @objc public var animated: Bool = true + + public override class func requiredKeyPaths() -> Set? { + return ["entryId"] + } + + public override class func jsonKeyPathsByPropertyKey() -> [AnyHashable: Any] { + return [ + "entryId": "entryId", + "animated": "animated", + ] + } +} + +@objc(StackReplaceParamModel) +public class StackReplaceParamModel: SPKMethodModel { + @objc public var path: String? + @objc public var search: NSDictionary? + + public override class func requiredKeyPaths() -> Set? { + return ["path"] + } + + public override class func jsonKeyPathsByPropertyKey() -> [AnyHashable: Any] { + return [ + "path": "path", + "search": "search", + ] + } +} + +@objc(StackResetParamModel) +public class StackResetParamModel: SPKMethodModel { + @objc public var entries: NSArray? + + public override class func requiredKeyPaths() -> Set? { + return ["entries"] + } + + public override class func jsonKeyPathsByPropertyKey() -> [AnyHashable: Any] { + return ["entries": "entries"] + } +} + +@objc(StackGetStateParamModel) +public class StackGetStateParamModel: SPKMethodModel { + public override class func requiredKeyPaths() -> Set? { + return [] + } +} + +@objc(StackStateResultModel) +public class StackStateResultModel: SPKMethodModel { + @objc public var version: NSNumber? + @objc public var entries: NSArray? + + public override class func jsonKeyPathsByPropertyKey() -> [AnyHashable: Any] { + return [ + "version": "version", + "entries": "entries", + ] + } +} + +@objc(StackPrefetchParamModel) +public class StackPrefetchParamModel: SPKMethodModel { + @objc public var path: String? + @objc public var search: NSDictionary? + + public override class func requiredKeyPaths() -> Set? { + return ["path"] + } + + public override class func jsonKeyPathsByPropertyKey() -> [AnyHashable: Any] { + return [ + "path": "path", + "search": "search", + ] + } +} + +@objc(StackSyncOwnLocationParamModel) +public class StackSyncOwnLocationParamModel: SPKMethodModel { + @objc public var path: String? + @objc public var search: NSDictionary? + + public override class func requiredKeyPaths() -> Set? { + return ["path"] + } + + public override class func jsonKeyPathsByPropertyKey() -> [AnyHashable: Any] { + return [ + "path": "path", + "search": "search", + ] + } +} diff --git a/packages/methods/sparkling-navigation/ios/Sources/Core/Protocols/StackRouterService.swift b/packages/methods/sparkling-navigation/ios/Sources/Core/Protocols/StackRouterService.swift new file mode 100644 index 00000000..97032966 --- /dev/null +++ b/packages/methods/sparkling-navigation/ios/Sources/Core/Protocols/StackRouterService.swift @@ -0,0 +1,22 @@ +// Copyright 2026 The Sparkling Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import Foundation +import SparklingMethod + +/// Optional host service implementing the hard-navigation stack protocol (RFC §4). +/// +/// Hosts that only implement `RouterService` continue to work for open/close. +/// Stack methods resolve this protocol separately and return `.notImplemented` +/// until a host registers an implementation (iOS-first S4 path). +public protocol StackRouterService { + func push(withParams params: StackPushParamModel, completion: @escaping PipeMethod.CompletionBlock) + func pop(withParams params: StackPopParamModel, completion: @escaping PipeMethod.CompletionBlock) + func popTo(withParams params: StackPopToParamModel, completion: @escaping PipeMethod.CompletionBlock) + func replace(withParams params: StackReplaceParamModel, completion: @escaping PipeMethod.CompletionBlock) + func reset(withParams params: StackResetParamModel, completion: @escaping PipeMethod.CompletionBlock) + func getState(withParams params: StackGetStateParamModel, completion: @escaping PipeMethod.CompletionBlock) + func prefetch(withParams params: StackPrefetchParamModel, completion: @escaping PipeMethod.CompletionBlock) + func syncOwnLocation(withParams params: StackSyncOwnLocationParamModel, completion: @escaping PipeMethod.CompletionBlock) +} diff --git a/packages/methods/sparkling-navigation/module.config.json b/packages/methods/sparkling-navigation/module.config.json index a63028ef..ea79a3d7 100644 --- a/packages/methods/sparkling-navigation/module.config.json +++ b/packages/methods/sparkling-navigation/module.config.json @@ -1,7 +1,7 @@ { "name": "sparkling-navigation", "platforms": ["android", "ios", "web"], - "version": "1.0.0", + "version": "1.1.0", "description": "Router methods for navigation and page management in Sparkling apps", "methods": { "open": { @@ -26,6 +26,97 @@ "returns": { "success": "boolean" } + }, + "stack.push": { + "description": "Push a hard-navigation stack entry (new native container)", + "parameters": { + "path": "string", + "search": "Record", + "presentation": "string", + "usePrefetched": "boolean", + "animated": "boolean" + }, + "returns": { + "code": "number", + "entryId": "string", + "msg": "string" + } + }, + "stack.pop": { + "description": "Pop the top hard-navigation stack entry", + "parameters": { + "result": "any", + "animated": "boolean" + }, + "returns": { + "code": "number", + "entryId": "string", + "msg": "string" + } + }, + "stack.popTo": { + "description": "Pop hard-navigation entries until the given entryId is top", + "parameters": { + "entryId": "string", + "animated": "boolean" + }, + "returns": { + "code": "number", + "entryId": "string", + "msg": "string" + } + }, + "stack.replace": { + "description": "Replace the top hard-navigation stack entry", + "parameters": { + "path": "string", + "search": "Record" + }, + "returns": { + "code": "number", + "entryId": "string", + "msg": "string" + } + }, + "stack.reset": { + "description": "Atomically declare the desired hard-navigation stack shape", + "parameters": { + "entries": "Array<{path:string,search?:Record,presentation?:string}>" + }, + "returns": { + "code": "number", + "entryId": "string", + "msg": "string" + } + }, + "stack.getState": { + "description": "Read the current hard-navigation stack state", + "parameters": {}, + "returns": { + "code": "number", + "data": "StackState" + } + }, + "stack.prefetch": { + "description": "Pre-create a container and preload its bundle without presenting", + "parameters": { + "path": "string", + "search": "Record" + }, + "returns": { + "code": "number", + "msg": "string" + } + }, + "stack.syncOwnLocation": { + "description": "Write back this container soft-nav path/search into the stack mirror", + "parameters": { + "path": "string", + "search": "Record" + }, + "returns": { + "code": "number" + } } }, "android": { diff --git a/packages/methods/sparkling-navigation/src/__tests__/__snapshots__/index.test.ts.snap b/packages/methods/sparkling-navigation/src/__tests__/__snapshots__/index.test.ts.snap index a88375b0..805a0d56 100644 --- a/packages/methods/sparkling-navigation/src/__tests__/__snapshots__/index.test.ts.snap +++ b/packages/methods/sparkling-navigation/src/__tests__/__snapshots__/index.test.ts.snap @@ -2,16 +2,35 @@ exports[`sparkling-navigation module exports snapshot testing for module structure should maintain consistent module export structure: module-export-structure 1`] = ` { - "exportCount": 3, + "exportCount": 13, "exportedKeys": [ "open", "close", "navigate", + "push", + "pop", + "popTo", + "replace", + "reset", + "getState", + "prefetch", + "syncOwnLocation", + "STACK_CHANGED_EVENT", + "subscribeStackChanged", ], "functionNames": [ "open", "close", "navigate", + "push", + "pop", + "popTo", + "replace", + "reset", + "getState", + "prefetch", + "syncOwnLocation", + "subscribeStackChanged", ], } `; diff --git a/packages/methods/sparkling-navigation/src/__tests__/index.test.ts b/packages/methods/sparkling-navigation/src/__tests__/index.test.ts index aa034bb3..971ca2a9 100644 --- a/packages/methods/sparkling-navigation/src/__tests__/index.test.ts +++ b/packages/methods/sparkling-navigation/src/__tests__/index.test.ts @@ -29,7 +29,20 @@ describe('sparkling-navigation module exports', () => { }); it('should export all required functions', async () => { - const expectedFunctions = ['open', 'close', 'navigate']; + const expectedFunctions = [ + 'open', + 'close', + 'navigate', + 'push', + 'pop', + 'popTo', + 'replace', + 'reset', + 'getState', + 'prefetch', + 'syncOwnLocation', + 'subscribeStackChanged', + ]; const moduleAny: Record = routerModule as unknown as Record; expectedFunctions.forEach(functionName => { expect(moduleAny[functionName]).toBeDefined(); @@ -65,7 +78,21 @@ describe('sparkling-navigation module exports', () => { const moduleAny: Record = routerModule as unknown as Record; const exportedKeys = Object.keys(moduleAny); - const expectedExports = ['open', 'close', 'navigate']; + const expectedExports = [ + 'open', + 'close', + 'navigate', + 'push', + 'pop', + 'popTo', + 'replace', + 'reset', + 'getState', + 'prefetch', + 'syncOwnLocation', + 'STACK_CHANGED_EVENT', + 'subscribeStackChanged', + ]; const unexpectedExports = exportedKeys.filter(key => expectedExports.indexOf(key) === -1); expect(unexpectedExports).toHaveLength(0); @@ -74,7 +101,7 @@ describe('sparkling-navigation module exports', () => { it('should export exactly the expected number of functions', async () => { const moduleAny: Record = routerModule as unknown as Record; const exportedFunctions = Object.keys(moduleAny).filter(key => typeof moduleAny[key] === 'function'); - expect(exportedFunctions).toHaveLength(3); // open, close and navigate + expect(exportedFunctions).toHaveLength(12); }); }); diff --git a/packages/methods/sparkling-navigation/src/__tests__/stack/stack.test.ts b/packages/methods/sparkling-navigation/src/__tests__/stack/stack.test.ts new file mode 100644 index 00000000..4e50a9d2 --- /dev/null +++ b/packages/methods/sparkling-navigation/src/__tests__/stack/stack.test.ts @@ -0,0 +1,105 @@ +/// +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { push } from '../../stack/push' +import { pop } from '../../stack/pop' +import { popTo } from '../../stack/popTo' +import { replace } from '../../stack/replace' +import { reset } from '../../stack/reset' +import { getState } from '../../stack/getState' +import { prefetch } from '../../stack/prefetch' +import { syncOwnLocation } from '../../stack/syncOwnLocation' +import { STACK_CHANGED_EVENT, subscribeStackChanged } from '../../stack/events' + +jest.mock('sparkling-method', () => ({ + call: jest.fn(), + on: jest.fn(), + off: jest.fn(), +}), { virtual: true }) + +describe('stack protocol JS API', () => { + let pipe: { call: jest.Mock; on: jest.Mock; off: jest.Mock } + + beforeEach(() => { + jest.clearAllMocks() + pipe = jest.requireMock('sparkling-method') + }) + + it('validates push path', async () => { + const result = await push({ path: '' }) + expect(result.code).toBe(-1) + expect(pipe.call).not.toHaveBeenCalled() + }) + + it('calls router.stack.push', async () => { + pipe.call.mockImplementation((_m: string, _p: unknown, cb: (v: unknown) => void) => { + cb({ code: 1, entryId: 'c1', msg: 'ok' }) + }) + const result = await push({ path: '/feed', search: { q: '1' } }) + expect(result).toEqual({ code: 1, entryId: 'c1', msg: 'ok' }) + expect(pipe.call).toHaveBeenCalledWith( + 'router.stack.push', + expect.objectContaining({ path: '/feed', search: { q: '1' } }), + expect.any(Function), + ) + }) + + it('calls pop / popTo / replace / reset', async () => { + pipe.call.mockImplementation((_m: string, _p: unknown, cb: (v: unknown) => void) => { + cb({ code: 1, entryId: 'x' }) + }) + await expect(pop({ result: { ok: true } })).resolves.toMatchObject({ code: 1 }) + await expect(popTo({ entryId: 'x' })).resolves.toMatchObject({ code: 1 }) + await expect(replace({ path: '/feed' })).resolves.toMatchObject({ code: 1 }) + await expect(reset({ entries: [{ path: '/', presentation: 'push' }] })).resolves.toMatchObject({ + code: 1, + }) + }) + + it('rejects empty popTo entryId', async () => { + await expect(popTo({ entryId: ' ' })).resolves.toMatchObject({ code: -1 }) + }) + + it('getState unwraps data payload', async () => { + pipe.call.mockImplementation((_m: string, _p: unknown, cb: (v: unknown) => void) => { + cb({ + code: 1, + data: { version: 3, entries: [{ id: 'a', path: '/', search: {}, bundle: 'home', presentation: 'push' }] }, + }) + }) + const state = await getState() + expect(state.version).toBe(3) + expect(state.entries).toHaveLength(1) + }) + + it('prefetch and syncOwnLocation call bridge methods', async () => { + pipe.call.mockImplementation((_m: string, _p: unknown, cb: (v: unknown) => void) => { + cb({ code: 1, msg: 'ok' }) + }) + await expect(prefetch({ path: '/feed' })).resolves.toEqual({ code: 1, msg: 'ok' }) + syncOwnLocation({ path: '/feed/1', search: { x: '1' } }) + expect(pipe.call).toHaveBeenCalledWith( + 'router.stack.syncOwnLocation', + { path: '/feed/1', search: { x: '1' } }, + expect.any(Function), + ) + }) + + it('subscribes to stack changed events', () => { + const listener = jest.fn() + const unsubscribe = subscribeStackChanged(listener) + expect(pipe.on).toHaveBeenCalledWith(STACK_CHANGED_EVENT, expect.any(Function)) + const wrapped = pipe.on.mock.calls[0][1] as (payload: unknown) => void + wrapped({ + state: { version: 1, entries: [] }, + reason: 'user-back-gesture', + }) + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ reason: 'user-back-gesture' }), + ) + unsubscribe() + expect(pipe.off).toHaveBeenCalled() + }) +}) diff --git a/packages/methods/sparkling-navigation/src/stack/call.ts b/packages/methods/sparkling-navigation/src/stack/call.ts new file mode 100644 index 00000000..bb0d6a70 --- /dev/null +++ b/packages/methods/sparkling-navigation/src/stack/call.ts @@ -0,0 +1,63 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import pipe from 'sparkling-method'; +import type { NavResult } from './types'; + +type PipeEnvelope = { + code?: number + msg?: string + data?: unknown + entryId?: string +} + +/** + * Invoke a stack bridge method and return the full NavResult envelope. + * Unlike pipe.callAsync (which unwraps `.data` on success), stack methods + * put `code` / `entryId` / `msg` at the top level of the callback payload. + */ +export function callStackMethod( + method: string, + params: TParams, +): Promise { + return new Promise((resolve) => { + pipe.call(method, params ?? {}, (raw: unknown) => { + const response = (raw ?? {}) as PipeEnvelope + const code = typeof response.code === 'number' ? response.code : -1 + if (code === 1) { + const entryId = + response.entryId ?? + (response.data as { entryId?: string } | undefined)?.entryId ?? + '' + resolve({ + code: 1, + entryId, + msg: response.msg ?? 'ok', + }) + return + } + resolve({ + code, + msg: response.msg ?? 'Unknown error', + entryId: response.entryId, + }) + }) + }) +} + +export function callStackMethodRaw( + method: string, + params: TParams, +): Promise { + return new Promise((resolve, reject) => { + pipe.call(method, params ?? {}, (raw: unknown) => { + const response = (raw ?? {}) as PipeEnvelope + const code = typeof response.code === 'number' ? response.code : -1 + if (code === 1) { + resolve((response.data ?? response) as TResult) + return + } + reject(new Error(response.msg ?? `Pipe call failed: ${method}`)) + }) + }) +} diff --git a/packages/methods/sparkling-navigation/src/stack/events.ts b/packages/methods/sparkling-navigation/src/stack/events.ts new file mode 100644 index 00000000..a495d886 --- /dev/null +++ b/packages/methods/sparkling-navigation/src/stack/events.ts @@ -0,0 +1,35 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import pipe from 'sparkling-method'; +import type { StackChangedEvent } from './types'; + +export const STACK_CHANGED_EVENT = 'sparklingStackChanged' as const + +export type StackChangedListener = (event: StackChangedEvent) => void + +/** + * Subscribe to global stack-changed broadcasts. + * Reuses the Lynx GlobalEventEmitter channel (same path as pageFinishBackEvent). + */ +export function subscribeStackChanged(listener: StackChangedListener): () => void { + const wrapped = (payload: unknown) => { + listener(normalizeStackChangedEvent(payload)) + } + pipe.on(STACK_CHANGED_EVENT, wrapped) + return () => { + pipe.off(STACK_CHANGED_EVENT, wrapped) + } +} + +function normalizeStackChangedEvent(payload: unknown): StackChangedEvent { + const raw = (payload ?? {}) as Partial + return { + state: { + version: raw.state?.version ?? 0, + entries: Array.isArray(raw.state?.entries) ? raw.state!.entries : [], + }, + reason: raw.reason ?? 'system', + result: raw.result, + } +} diff --git a/packages/methods/sparkling-navigation/src/stack/getState.ts b/packages/methods/sparkling-navigation/src/stack/getState.ts new file mode 100644 index 00000000..18545f29 --- /dev/null +++ b/packages/methods/sparkling-navigation/src/stack/getState.ts @@ -0,0 +1,15 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { callStackMethodRaw } from './call'; +import type { StackState } from './types'; + +export function getState(): Promise { + return callStackMethodRaw('router.stack.getState', {}).then((raw) => { + const state = raw as StackState + if (!state || typeof state.version !== 'number' || !Array.isArray(state.entries)) { + return { version: 0, entries: [] } + } + return state + }) +} diff --git a/packages/methods/sparkling-navigation/src/stack/index.ts b/packages/methods/sparkling-navigation/src/stack/index.ts new file mode 100644 index 00000000..1aeaa6d2 --- /dev/null +++ b/packages/methods/sparkling-navigation/src/stack/index.ts @@ -0,0 +1,28 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +export { push } from './push'; +export { pop } from './pop'; +export { popTo } from './popTo'; +export { replace } from './replace'; +export { reset } from './reset'; +export { getState } from './getState'; +export { prefetch } from './prefetch'; +export { syncOwnLocation } from './syncOwnLocation'; +export { STACK_CHANGED_EVENT, subscribeStackChanged } from './events'; +export type { StackChangedListener } from './events'; +export type { + StackEntry, + StackState, + NavResult, + StackChangeReason, + StackChangedEvent, + StackPushRequest, + StackPopRequest, + StackPopToRequest, + StackReplaceRequest, + StackResetRequest, + StackPrefetchRequest, + StackSyncOwnLocationRequest, + NativeStackProtocol, +} from './types'; diff --git a/packages/methods/sparkling-navigation/src/stack/pop.ts b/packages/methods/sparkling-navigation/src/stack/pop.ts new file mode 100644 index 00000000..7190dcbb --- /dev/null +++ b/packages/methods/sparkling-navigation/src/stack/pop.ts @@ -0,0 +1,12 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { callStackMethod } from './call'; +import type { NavResult, StackPopRequest } from './types'; + +export function pop(req?: StackPopRequest): Promise { + return callStackMethod('router.stack.pop', { + result: req?.result, + animated: req?.animated ?? true, + }) +} diff --git a/packages/methods/sparkling-navigation/src/stack/popTo.ts b/packages/methods/sparkling-navigation/src/stack/popTo.ts new file mode 100644 index 00000000..c1a155bd --- /dev/null +++ b/packages/methods/sparkling-navigation/src/stack/popTo.ts @@ -0,0 +1,15 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { callStackMethod } from './call'; +import type { NavResult, StackPopToRequest } from './types'; + +export function popTo(req: StackPopToRequest): Promise { + if (!req || typeof req.entryId !== 'string' || !req.entryId.trim()) { + return Promise.resolve({ code: -1, msg: 'Invalid params: entryId must be a non-empty string' }) + } + return callStackMethod('router.stack.popTo', { + entryId: req.entryId.trim(), + animated: req.animated ?? true, + }) +} diff --git a/packages/methods/sparkling-navigation/src/stack/prefetch.ts b/packages/methods/sparkling-navigation/src/stack/prefetch.ts new file mode 100644 index 00000000..bbf39cc9 --- /dev/null +++ b/packages/methods/sparkling-navigation/src/stack/prefetch.ts @@ -0,0 +1,29 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import pipe from 'sparkling-method'; +import type { StackPrefetchRequest } from './types'; + +export function prefetch( + req: StackPrefetchRequest, +): Promise<{ code: number; msg?: string }> { + if (!req || typeof req.path !== 'string' || !req.path.trim()) { + return Promise.resolve({ code: -1, msg: 'Invalid params: path must be a non-empty string' }) + } + return new Promise((resolve) => { + pipe.call( + 'router.stack.prefetch', + { + path: req.path.trim(), + search: req.search ?? {}, + }, + (raw: unknown) => { + const response = (raw ?? {}) as { code?: number; msg?: string } + resolve({ + code: typeof response.code === 'number' ? response.code : -1, + msg: response.msg, + }) + }, + ) + }) +} diff --git a/packages/methods/sparkling-navigation/src/stack/push.ts b/packages/methods/sparkling-navigation/src/stack/push.ts new file mode 100644 index 00000000..c048458e --- /dev/null +++ b/packages/methods/sparkling-navigation/src/stack/push.ts @@ -0,0 +1,18 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { callStackMethod } from './call'; +import type { NavResult, StackPushRequest } from './types'; + +export function push(req: StackPushRequest): Promise { + if (!req || typeof req.path !== 'string' || !req.path.trim()) { + return Promise.resolve({ code: -1, msg: 'Invalid params: path must be a non-empty string' }) + } + return callStackMethod('router.stack.push', { + path: req.path.trim(), + search: req.search ?? {}, + presentation: req.presentation ?? 'push', + usePrefetched: req.usePrefetched ?? false, + animated: req.animated ?? true, + }) +} diff --git a/packages/methods/sparkling-navigation/src/stack/replace.ts b/packages/methods/sparkling-navigation/src/stack/replace.ts new file mode 100644 index 00000000..25a95e2a --- /dev/null +++ b/packages/methods/sparkling-navigation/src/stack/replace.ts @@ -0,0 +1,15 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { callStackMethod } from './call'; +import type { NavResult, StackReplaceRequest } from './types'; + +export function replace(req: StackReplaceRequest): Promise { + if (!req || typeof req.path !== 'string' || !req.path.trim()) { + return Promise.resolve({ code: -1, msg: 'Invalid params: path must be a non-empty string' }) + } + return callStackMethod('router.stack.replace', { + path: req.path.trim(), + search: req.search ?? {}, + }) +} diff --git a/packages/methods/sparkling-navigation/src/stack/reset.ts b/packages/methods/sparkling-navigation/src/stack/reset.ts new file mode 100644 index 00000000..58f62897 --- /dev/null +++ b/packages/methods/sparkling-navigation/src/stack/reset.ts @@ -0,0 +1,14 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { callStackMethod } from './call'; +import type { NavResult, StackResetRequest } from './types'; + +export function reset(req: StackResetRequest): Promise { + if (!req || !Array.isArray(req.entries)) { + return Promise.resolve({ code: -1, msg: 'Invalid params: entries must be an array' }) + } + return callStackMethod('router.stack.reset', { + entries: req.entries, + }) +} diff --git a/packages/methods/sparkling-navigation/src/stack/syncOwnLocation.ts b/packages/methods/sparkling-navigation/src/stack/syncOwnLocation.ts new file mode 100644 index 00000000..e1ddfdc1 --- /dev/null +++ b/packages/methods/sparkling-navigation/src/stack/syncOwnLocation.ts @@ -0,0 +1,25 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import pipe from 'sparkling-method'; +import type { StackSyncOwnLocationRequest } from './types'; + +/** + * Fire-and-forget write-back of this container's soft-nav location. + * Native uses it to keep the stack mirror consistent with deep routes. + */ +export function syncOwnLocation(req: StackSyncOwnLocationRequest): void { + if (!req || typeof req.path !== 'string' || !req.path.trim()) { + return + } + pipe.call( + 'router.stack.syncOwnLocation', + { + path: req.path.trim(), + search: req.search ?? {}, + }, + () => { + // intentionally ignore — sync is best-effort + }, + ) +} diff --git a/packages/methods/sparkling-navigation/src/stack/types.d.ts b/packages/methods/sparkling-navigation/src/stack/types.d.ts new file mode 100644 index 00000000..d9cae6d6 --- /dev/null +++ b/packages/methods/sparkling-navigation/src/stack/types.d.ts @@ -0,0 +1,92 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +/** One hard-navigation entry = one native container. */ +export interface StackEntry { + id: string + path: string + search: Record + bundle: string + presentation: 'push' | 'modal' +} + +export interface StackState { + version: number + entries: StackEntry[] +} + +export type NavResult = + | { code: 1; entryId: string; msg?: string } + | { code: number; msg: string; entryId?: string } + +export type StackChangeReason = + | 'push' + | 'pop' + | 'replace' + | 'reset' + | 'user-back-gesture' + | 'user-back-button' + | 'system' + | 'sync' + +export interface StackChangedEvent { + state: StackState + reason: StackChangeReason + result?: { forEntryId: string; value: unknown } +} + +export interface StackPushRequest { + path: string + search?: Record + presentation?: 'push' | 'modal' + usePrefetched?: boolean + animated?: boolean +} + +export interface StackPopRequest { + result?: unknown + animated?: boolean +} + +export interface StackPopToRequest { + entryId: string + animated?: boolean +} + +export interface StackReplaceRequest { + path: string + search?: Record +} + +export interface StackResetRequest { + entries: Array<{ + path: string + search?: Record + presentation?: 'push' | 'modal' + }> +} + +export interface StackPrefetchRequest { + path: string + search?: Record +} + +export interface StackSyncOwnLocationRequest { + path: string + search: Record +} + +export interface NativeStackProtocol { + push(req: StackPushRequest): Promise + pop(req?: StackPopRequest): Promise + popTo(req: StackPopToRequest): Promise + replace(req: StackReplaceRequest): Promise + reset(req: StackResetRequest): Promise + getState(): Promise + prefetch(req: StackPrefetchRequest): Promise<{ code: number; msg?: string }> + syncOwnLocation(req: StackSyncOwnLocationRequest): void +} + +/** Global event name broadcast to all live containers. */ +export declare const STACK_CHANGED_EVENT: 'sparklingStackChanged' diff --git a/packages/sparkling-router-plugin/README.md b/packages/sparkling-router-plugin/README.md new file mode 100644 index 00000000..1158b88d --- /dev/null +++ b/packages/sparkling-router-plugin/README.md @@ -0,0 +1,54 @@ +# sparkling-router-plugin + +Codegen for Sparkling Router container boundaries. + +## What it does + +Scans a TanStack-style `src/routes/**` tree for `_container.tsx` / `_container.modal.tsx` markers and emits: + +1. **`route-manifest.gen.json`** — serializable `RouteManifest` injected into every bundle +2. **Per-container entry stubs** — `src/generated/containers//index.tsx` +3. **`rspeedy-entries.gen.json`** — map suitable for `source.entry` in `lynx.config.ts` + +## S3′ generator decision + +`@tanstack/router-plugin` remains the right tool for **per-container** typed `routeTree.gen.ts`. Its customization surface is not enough to express hard container boundaries + multi-entry partitioning, so this package owns that layer while keeping TanStack file-route **syntax** unchanged (RFC P5). + +## Marker convention + +``` +src/routes/ + __root.tsx + index.tsx # single-page container "/" + feed/ + _container.tsx # hard boundary → bundle "feed" + index.tsx # soft "/feed" + $postId.tsx # soft "/feed/$postId" + settings/ + _container.modal.tsx # modal presentation + index.tsx +``` + +Pair with TanStack's `routeFileIgnorePattern: '_container'` so the upstream +generator skips markers (S3′ naming decision: keep `_container*.tsx` spelling; +do not require the `-` ignore prefix). + +## Usage + +```ts +import { sparklingRouter } from 'sparkling-router-plugin/rspack' + +// lynx.config.ts tools.rspack.plugins: +sparklingRouter({ + routesDirectory: 'src/routes', + schemeBase: 'hybrid://lynxview_page', +}) +``` + +Or imperatively: + +```ts +import { generateSparklingRoutes } from 'sparkling-router-plugin' + +generateSparklingRoutes(process.cwd()) +``` diff --git a/packages/sparkling-router-plugin/index.ts b/packages/sparkling-router-plugin/index.ts new file mode 100644 index 00000000..fc49a10d --- /dev/null +++ b/packages/sparkling-router-plugin/index.ts @@ -0,0 +1,22 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +export { scanRouteContainers } from './src/scan.js' +export { generateSparklingRoutes } from './src/generate.js' +export { sparklingRouterPlugin } from './src/plugin.js' +export { + routePathFromFile, + isContainerMarkerName, + presentationFromMarkerName, + containerIdFromDir, +} from './src/path-from-file.js' +export type { + Presentation, + ScannedRouteFile, + ContainerPartition, + ScanResult, + PluginOptions, +} from './src/types.js' +export type { GenerateResult } from './src/generate.js' +export type { SparklingRouterPlugin } from './src/plugin.js' diff --git a/packages/sparkling-router-plugin/jest.config.ts b/packages/sparkling-router-plugin/jest.config.ts new file mode 100644 index 00000000..aa73ac8f --- /dev/null +++ b/packages/sparkling-router-plugin/jest.config.ts @@ -0,0 +1,18 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import type { Config } from 'jest'; + +const config: Config = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/src'], + testMatch: ['**/__tests__/**/*.test.ts'], + moduleFileExtensions: ['ts', 'js', 'json'], + moduleNameMapper: { + '^(\\.{1,2}/.*)\\.js$': '$1', + }, + verbose: false, +}; + +export default config; diff --git a/packages/sparkling-router-plugin/package.json b/packages/sparkling-router-plugin/package.json new file mode 100644 index 00000000..ca298855 --- /dev/null +++ b/packages/sparkling-router-plugin/package.json @@ -0,0 +1,50 @@ +{ + "name": "sparkling-router-plugin", + "version": "2.1.0-rc.12", + "description": "File-route codegen for Sparkling Router — container boundaries, multi-entry, route manifest", + "homepage": "https://tiktok.github.io/sparkling/", + "repository": { + "type": "git", + "url": "https://github.com/tiktok/sparkling", + "directory": "packages/sparkling-router-plugin" + }, + "license": "Apache-2.0", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./rspack": { + "types": "./dist/rspack.d.ts", + "default": "./dist/rspack.js" + } + }, + "files": [ + "dist", + "src", + "index.ts", + "rspack.ts", + "README.md" + ], + "scripts": { + "build": "tsc", + "test": "jest", + "test:coverage": "jest --coverage" + }, + "dependencies": { + "sparkling-router": "workspace:*" + }, + "devDependencies": { + "@types/jest": "^29.5.12", + "@types/node": "^22.10.0", + "jest": "^29.7.0", + "ts-jest": "^29.1.2", + "typescript": "^5.8.3" + }, + "engines": { + "node": "^22 || ^24" + } +} diff --git a/packages/sparkling-router-plugin/rspack.ts b/packages/sparkling-router-plugin/rspack.ts new file mode 100644 index 00000000..c5a2009f --- /dev/null +++ b/packages/sparkling-router-plugin/rspack.ts @@ -0,0 +1,6 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +export { sparklingRouterPlugin as sparklingRouter } from './src/plugin.js' +export type { PluginOptions } from './src/types.js' diff --git a/packages/sparkling-router-plugin/src/__tests__/scan.test.ts b/packages/sparkling-router-plugin/src/__tests__/scan.test.ts new file mode 100644 index 00000000..fb5a4f20 --- /dev/null +++ b/packages/sparkling-router-plugin/src/__tests__/scan.test.ts @@ -0,0 +1,58 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { generateSparklingRoutes } from '../generate' +import { routePathFromFile } from '../path-from-file' +import { scanRouteContainers } from '../scan' + +function writeTree(root: string, files: Record) { + for (const [rel, body] of Object.entries(files)) { + const abs = path.join(root, rel) + fs.mkdirSync(path.dirname(abs), { recursive: true }) + fs.writeFileSync(abs, body) + } +} + +describe('sparkling-router-plugin scan/codegen', () => { + it('maps file paths to URL patterns', () => { + expect(routePathFromFile('index.tsx')).toBe('/') + expect(routePathFromFile('feed/index.tsx')).toBe('/feed') + expect(routePathFromFile('feed/$postId.tsx')).toBe('/feed/$postId') + expect(routePathFromFile('user.$id.tsx')).toBe('/user/$id') + expect(routePathFromFile('feed/_container.tsx')).toBeUndefined() + expect(routePathFromFile('__root.tsx')).toBeUndefined() + }) + + it('partitions containers by _container markers', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'spk-router-')) + const routes = path.join(tmp, 'src/routes') + writeTree(routes, { + '__root.tsx': 'export {}', + 'index.tsx': 'export {}', + 'feed/_container.tsx': 'export default {}', + 'feed/index.tsx': 'export {}', + 'feed/$postId.tsx': 'export {}', + 'settings/_container.modal.tsx': 'export default {}', + 'settings/index.tsx': 'export {}', + 'user.$id.tsx': 'export {}', + }) + + const scan = scanRouteContainers(routes) + const byId = Object.fromEntries(scan.containers.map((c) => [c.id, c])) + + expect(byId.feed?.presentation).toBe('push') + expect(byId.feed?.routes.map((r) => r.path).sort()).toEqual(['/feed', '/feed/$postId']) + expect(byId.settings?.presentation).toBe('modal') + expect(byId['user.id']?.routes.map((r) => r.path)).toEqual(['/user/$id']) + expect(byId.main?.routes.map((r) => r.path) ?? byId['']?.routes).toBeDefined() + + const generated = generateSparklingRoutes(tmp) + expect(generated.manifest.containers.length).toBeGreaterThanOrEqual(3) + expect(fs.existsSync(generated.manifestPath)).toBe(true) + expect(Object.keys(generated.entries).length).toBe(generated.manifest.containers.length) + }) +}) diff --git a/packages/sparkling-router-plugin/src/generate.ts b/packages/sparkling-router-plugin/src/generate.ts new file mode 100644 index 00000000..9771394a --- /dev/null +++ b/packages/sparkling-router-plugin/src/generate.ts @@ -0,0 +1,99 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import fs from 'node:fs' +import path from 'node:path' +import type { RouteManifest } from 'sparkling-router' +import { scanRouteContainers } from './scan.js' +import type { PluginOptions, ScanResult } from './types.js' + +export interface GenerateResult { + scan: ScanResult + manifest: RouteManifest + manifestPath: string + entries: Record + entriesPath?: string + containerDirs: string[] +} + +const DEFAULTS = { + routesDirectory: 'src/routes', + manifestOutput: 'src/route-manifest.gen.json', + containersOutput: 'src/generated/containers', + schemeBase: 'hybrid://lynxview_page', + manifestVersion: '1', + emitEntries: true, +} + +/** + * Scan routes, emit: + * - global `route-manifest.gen.json` + * - per-container entry stubs under `generated/containers//` + * - optional `rspeedy-entries.gen.json` for `source.entry` injection + */ +export function generateSparklingRoutes( + projectRoot: string, + options: PluginOptions = {}, +): GenerateResult { + const opts = { ...DEFAULTS, ...options } + const routesDir = path.resolve(projectRoot, opts.routesDirectory) + const scan = scanRouteContainers(routesDir) + + const manifest: RouteManifest = { + version: opts.manifestVersion, + scheme: { base: opts.schemeBase }, + containers: scan.containers.map((c) => ({ + bundle: `${c.id}.lynx.bundle`, + presentation: c.presentation, + routes: c.routes.length > 0 ? c.routes : [{ path: `/${c.id.replace(/\./g, '/')}` }], + containerOptions: c.containerOptions, + })), + } + + const manifestPath = path.resolve(projectRoot, opts.manifestOutput) + fs.mkdirSync(path.dirname(manifestPath), { recursive: true }) + fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8') + + const containersRoot = path.resolve(projectRoot, opts.containersOutput) + fs.mkdirSync(containersRoot, { recursive: true }) + + const entries: Record = {} + const containerDirs: string[] = [] + + for (const container of scan.containers) { + const dir = path.join(containersRoot, container.id) + fs.mkdirSync(dir, { recursive: true }) + containerDirs.push(dir) + + const entryFile = path.join(dir, 'index.tsx') + const routesLiteral = JSON.stringify(container.routes.map((r) => r.path), null, 2) + const contents = `/* eslint-disable */ +// auto-generated by sparkling-router-plugin — do not edit +import 'url-search-params-polyfill' +import { root } from '@lynx-js/react' + +export const CONTAINER_ID = ${JSON.stringify(container.id)} +export const OWNED_ROUTES = ${routesLiteral} as const +export const PRESENTATION = ${JSON.stringify(container.presentation)} as const + +// Host apps should replace this stub with createSparklingHistory + RouterProvider. +root.render( + + Container ${container.id} + , +) +` + + fs.writeFileSync(entryFile, contents, 'utf8') + entries[container.id] = path.relative(projectRoot, entryFile).replace(/\\/g, '/') + } + + let entriesPath: string | undefined + if (opts.emitEntries) { + entriesPath = path.resolve(projectRoot, path.dirname(opts.manifestOutput), 'rspeedy-entries.gen.json') + fs.writeFileSync(entriesPath, `${JSON.stringify(entries, null, 2)}\n`, 'utf8') + } + + return { scan, manifest, manifestPath, entries, entriesPath, containerDirs } +} diff --git a/packages/sparkling-router-plugin/src/path-from-file.ts b/packages/sparkling-router-plugin/src/path-from-file.ts new file mode 100644 index 00000000..fc6350ce --- /dev/null +++ b/packages/sparkling-router-plugin/src/path-from-file.ts @@ -0,0 +1,93 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +/** + * Convert a TanStack-style route file path (relative to routesDir) into a URL path pattern. + * + * Examples: + * index.tsx → / + * feed/index.tsx → /feed + * feed/$postId.tsx → /feed/$postId + * user.$id.tsx → /user/$id + * settings/_container.tsx → (skipped — marker) + * __root.tsx → (skipped — root) + */ +export function routePathFromFile(relativePath: string): string | undefined { + const posix = relativePath.replace(/\\/g, '/') + const base = posix.replace(/\.(tsx|ts|jsx|js)$/, '') + + if ( + base === '__root' || + base.endsWith('/__root') || + isContainerMarkerName(base.split('/').pop() ?? '') + ) { + return undefined + } + + const segments = base.split('/').filter(Boolean) + const urlSegments: string[] = [] + + for (const segment of segments) { + if (segment.startsWith('_') && !segment.startsWith('_layout')) { + // pathless layout / grouping — skip segment name, keep children + if (segment === '_layout' || segment.startsWith('_layout.')) { + continue + } + if (segment.startsWith('(') || segment.endsWith(')')) { + continue + } + // other underscore files (loaders etc.) are not routes + if (!segment.includes('.')) { + continue + } + } + + if (segment === 'index') { + continue + } + + // Flatten dotted segments: user.$id → user / $id + for (const part of segment.split('.')) { + if (!part || part === 'index') { + continue + } + if (part.startsWith('_')) { + continue + } + urlSegments.push(part) + } + } + + if (urlSegments.length === 0) { + return '/' + } + return `/${urlSegments.join('/')}` +} + +export function isContainerMarkerName(fileName: string): boolean { + // _container.tsx | _container.modal.tsx + // Also accept TanStack ignore-prefixed forms: -_container.tsx + const normalized = fileName.startsWith('-') ? fileName.slice(1) : fileName + return ( + normalized === '_container' || + normalized === '_container.modal' || + normalized.startsWith('_container.') + ) +} + +export function presentationFromMarkerName(fileName: string): 'push' | 'modal' { + const normalized = fileName.startsWith('-') ? fileName.slice(1) : fileName + if (normalized.includes('.modal')) { + return 'modal' + } + return 'push' +} + +export function containerIdFromDir(dir: string, fallbackPath: string): string { + if (!dir || dir === '.') { + const cleaned = fallbackPath.replace(/^\//, '').replace(/\//g, '.') || 'main' + return cleaned === '' ? 'main' : cleaned.replace(/\$/g, '') + } + return dir.replace(/\\/g, '/').replace(/\//g, '.') +} diff --git a/packages/sparkling-router-plugin/src/plugin.ts b/packages/sparkling-router-plugin/src/plugin.ts new file mode 100644 index 00000000..11942448 --- /dev/null +++ b/packages/sparkling-router-plugin/src/plugin.ts @@ -0,0 +1,75 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import path from 'node:path' +import { generateSparklingRoutes } from './generate.js' +import type { PluginOptions } from './types.js' + +export interface SparklingRouterPlugin { + name: string + apply(compiler: { + hooks?: { + initialize?: { tap: (name: string, fn: () => void) => void } + beforeCompile?: { tapAsync: (name: string, fn: (params: unknown, cb: (err?: Error) => void) => void) => void } + watchRun?: { tap: (name: string, fn: () => void) => void } + } + context?: string + }): void + /** Imperative generate for CLIs / tests. */ + generate(projectRoot?: string): ReturnType +} + +/** + * Lightweight rspack/rsbuild plugin. + * + * S3′ conclusion: TanStack's generator customization surface is insufficient for + * `_container` multi-entry partitioning, so this plugin owns boundary scan + + * manifest/entry emit. Pair with `@tanstack/router-plugin/rspack` for per-bundle + * typed `routeTree.gen.ts` inside each container subtree (or point its + * `routesDirectory` at a virtualized subset). + */ +export function sparklingRouterPlugin(options: PluginOptions = {}): SparklingRouterPlugin { + let lastFingerprint = '' + + const run = (projectRoot: string) => { + const result = generateSparklingRoutes(projectRoot, options) + const fingerprint = JSON.stringify(result.manifest) + if (fingerprint !== lastFingerprint) { + lastFingerprint = fingerprint + // eslint-disable-next-line no-console + console.log( + `[sparkling-router-plugin] wrote manifest (${result.manifest.containers.length} containers) → ${path.relative(projectRoot, result.manifestPath)}`, + ) + } + return result + } + + return { + name: 'sparkling-router-plugin', + apply(compiler) { + const root = compiler.context || process.cwd() + const tap = () => { + try { + run(root) + } catch (error) { + // eslint-disable-next-line no-console + console.error('[sparkling-router-plugin] generate failed', error) + } + } + compiler.hooks?.initialize?.tap('sparkling-router-plugin', tap) + compiler.hooks?.watchRun?.tap('sparkling-router-plugin', tap) + compiler.hooks?.beforeCompile?.tapAsync('sparkling-router-plugin', (_params, cb) => { + try { + run(root) + cb() + } catch (error) { + cb(error as Error) + } + }) + }, + generate(projectRoot = process.cwd()) { + return run(projectRoot) + }, + } +} diff --git a/packages/sparkling-router-plugin/src/scan.ts b/packages/sparkling-router-plugin/src/scan.ts new file mode 100644 index 00000000..d7fcc6c9 --- /dev/null +++ b/packages/sparkling-router-plugin/src/scan.ts @@ -0,0 +1,156 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import fs from 'node:fs' +import path from 'node:path' +import { + containerIdFromDir, + isContainerMarkerName, + presentationFromMarkerName, + routePathFromFile, +} from './path-from-file.js' +import type { ContainerPartition, ScanResult } from './types.js' + +const ROUTE_EXT = /\.(tsx|ts|jsx|js)$/ + +/** + * Scan a TanStack-style `routes/` tree and partition files by `_container` boundaries. + * + * Rules (RFC §5.1): + * - `_container.tsx` / `_container.modal.tsx` marks a hard container for that subtree + * - Top-level route files without a marker each become a single-page container + * - `__root.tsx` is global (type/context only) and is not a container + */ +export function scanRouteContainers(routesDirectory: string): ScanResult { + const routesDir = path.resolve(routesDirectory) + if (!fs.existsSync(routesDir)) { + return { routesDir, containers: [], rootFiles: [] } + } + + const allFiles = listRouteFiles(routesDir) + const rootFiles = allFiles.filter((f) => f === '__root.tsx' || f === '__root.ts') + + const markers = new Map() + for (const rel of allFiles) { + const name = path.posix.basename(rel).replace(ROUTE_EXT, '') + if (!isContainerMarkerName(name)) { + continue + } + const dir = path.posix.dirname(rel) + markers.set(dir === '.' ? '' : dir, { + file: rel, + presentation: presentationFromMarkerName(name), + }) + } + + const containerMap = new Map() + + const ensureContainer = ( + id: string, + dir: string, + presentation: 'push' | 'modal', + markerFile?: string, + ): ContainerPartition => { + let existing = containerMap.get(id) + if (!existing) { + existing = { + id, + dir, + presentation, + markerFile, + routeFiles: [], + routes: [], + } + containerMap.set(id, existing) + } + return existing + } + + for (const rel of allFiles) { + if (rel === '__root.tsx' || rel === '__root.ts') { + continue + } + const name = path.posix.basename(rel).replace(ROUTE_EXT, '') + if (isContainerMarkerName(name)) { + const dir = path.posix.dirname(rel) + const dirKey = dir === '.' ? '' : dir + const id = containerIdFromDir(dirKey, dirKey || 'main') + ensureContainer(id, dirKey, presentationFromMarkerName(name), rel) + continue + } + + const ownerDir = findOwnerDir(rel, markers) + const routePath = routePathFromFile(rel) + if (!routePath) { + continue + } + + if (ownerDir !== undefined) { + const marker = markers.get(ownerDir)! + const id = containerIdFromDir(ownerDir, routePath) + const partition = ensureContainer(id, ownerDir, marker.presentation, marker.file) + partition.routeFiles.push(rel) + if (!partition.routes.some((r) => r.path === routePath)) { + partition.routes.push({ path: routePath }) + } + } else { + // Single-page container for top-level (or unmarked) route files. + const id = containerIdFromDir('', routePath) + const partition = ensureContainer(id, '', 'push') + partition.routeFiles.push(rel) + if (!partition.routes.some((r) => r.path === routePath)) { + partition.routes.push({ path: routePath }) + } + } + } + + // Ensure marker-only containers still appear (empty routes allowed during authoring). + for (const [dir, marker] of markers) { + const id = containerIdFromDir(dir, dir || 'main') + ensureContainer(id, dir, marker.presentation, marker.file) + } + + const containers = [...containerMap.values()].sort((a, b) => a.id.localeCompare(b.id)) + return { routesDir, containers, rootFiles } +} + +function findOwnerDir( + relativeFile: string, + markers: Map, +): string | undefined { + let dir = path.posix.dirname(relativeFile) + if (dir === '.') { + dir = '' + } + while (true) { + if (markers.has(dir)) { + return dir + } + if (!dir) { + return undefined + } + const parent = path.posix.dirname(dir) + dir = parent === '.' ? '' : parent + } +} + +function listRouteFiles(routesDir: string): string[] { + const results: string[] = [] + + const walk = (absDir: string, relDir: string) => { + const entries = fs.readdirSync(absDir, { withFileTypes: true }) + for (const entry of entries) { + const rel = relDir ? `${relDir}/${entry.name}` : entry.name + const abs = path.join(absDir, entry.name) + if (entry.isDirectory()) { + walk(abs, rel) + } else if (ROUTE_EXT.test(entry.name)) { + results.push(rel.replace(/\\/g, '/')) + } + } + } + + walk(routesDir, '') + return results.sort() +} diff --git a/packages/sparkling-router-plugin/src/types.ts b/packages/sparkling-router-plugin/src/types.ts new file mode 100644 index 00000000..b3a1521d --- /dev/null +++ b/packages/sparkling-router-plugin/src/types.ts @@ -0,0 +1,51 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +export type Presentation = 'push' | 'modal' + +export interface ScannedRouteFile { + /** Absolute file path. */ + filePath: string + /** Path relative to routes directory, posix style. */ + relativePath: string + /** True when this file marks a hard container boundary. */ + isContainerMarker: boolean + presentation: Presentation +} + +export interface ContainerPartition { + /** Stable bundle / entry name (e.g. `feed`, `settings`). */ + id: string + presentation: Presentation + /** Directory that owns this container (relative to routesDir), '' for root singles. */ + dir: string + /** Marker file path if present. */ + markerFile?: string + /** Route files belonging to this container (relative to routesDir). */ + routeFiles: string[] + /** URL path patterns owned by this container. */ + routes: Array<{ path: string }> + containerOptions?: Record +} + +export interface ScanResult { + routesDir: string + containers: ContainerPartition[] + rootFiles: string[] +} + +export interface PluginOptions { + /** Directory containing TanStack-style route files. Default: `src/routes`. */ + routesDirectory?: string + /** Output path for the generated manifest JSON. Default: `src/route-manifest.gen.json`. */ + manifestOutput?: string + /** Output directory for per-container generated stubs. Default: `src/generated/containers`. */ + containersOutput?: string + /** Scheme base written into the manifest. */ + schemeBase?: string + /** Manifest version string (skew reserved). */ + manifestVersion?: string + /** When true, also emit rspeedy `source.entry` map JSON. Default: true. */ + emitEntries?: boolean +} diff --git a/packages/sparkling-router-plugin/tsconfig.json b/packages/sparkling-router-plugin/tsconfig.json new file mode 100644 index 00000000..41c3b96e --- /dev/null +++ b/packages/sparkling-router-plugin/tsconfig.json @@ -0,0 +1,23 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": ".", + "noEmit": false, + "declaration": true, + "emitDeclarationOnly": false, + "module": "ES2020", + "target": "ES2020", + "moduleResolution": "node", + "lib": ["ES2020"], + "types": ["node", "jest"] + }, + "include": ["./src/**/*", "index.ts", "rspack.ts"], + "exclude": [ + "node_modules", + "dist", + "**/*.test.ts", + "**/*.spec.ts", + "**/__tests__/**" + ] +} diff --git a/packages/sparkling-router/README.md b/packages/sparkling-router/README.md new file mode 100644 index 00000000..59cd3807 --- /dev/null +++ b/packages/sparkling-router/README.md @@ -0,0 +1,54 @@ +# sparkling-router + +URL-first dual-layer navigation for Sparkling apps. + +- **Soft navigation** — in-container TanStack Router + memory history (same LynxView / JS runtime) +- **Hard navigation** — cross-container stack protocol (`push` / `pop` / `reset` / …) mirrored via `GlobalStackMirror` + +TanStack touchpoints are intentionally narrow: `RouterHistory` (`CompositeHistory`) and the file-route generator in `sparkling-router-plugin`. + +## Install + +```bash +pnpm add sparkling-router @tanstack/react-router url-search-params-polyfill +pnpm add -D sparkling-router-plugin @tanstack/router-plugin +``` + +## Usage + +```ts +import 'url-search-params-polyfill' +import { createRouter, RouterProvider } from '@tanstack/react-router' +import { + createSparklingHistory, + resolveContainerIdentity, + type RouteManifest, +} from 'sparkling-router' +import { routeTree } from './routeTree.gen' +import manifest from './route-manifest.gen.json' + +const runtime = createSparklingHistory({ + manifest: manifest as RouteManifest, + container: resolveContainerIdentity(manifest as RouteManifest, 'feed'), + queryItems: lynx.__globalProps?.queryItems, + // memoryStack: true // for demos / tests without native stack methods +}) + +const router = createRouter({ + routeTree, + history: runtime.history, + isServer: false, +}) +``` + +## Package layout + +| Export | Role | +| --- | --- | +| `createCompositeHistory` | Soft/hard `RouterHistory` | +| `createSparklingHistory` | Convenience wiring | +| `GlobalStackMirror` | Read-only stack subscription | +| `createInMemoryStackProtocol` | Test / demo stack | +| `pathToScheme` | Manifest → `hybrid://…` translation | + +See the Sparkling Router RFC for the full stack protocol and container-boundary authoring model. diff --git a/packages/sparkling-router/index.ts b/packages/sparkling-router/index.ts new file mode 100644 index 00000000..29ee79c8 --- /dev/null +++ b/packages/sparkling-router/index.ts @@ -0,0 +1,61 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +export type { + StackEntry, + StackState, + NavResult, + StackChangeReason, + StackChangedEvent, + NativeStackProtocol, + RouteManifest, + RouteManifestContainer, + ContainerIdentity, +} from './src/types.js' + +export { + DEFAULT_SCHEME_BASE, + STACK_CHANGED_EVENT, + PATH_QUERY_KEY, +} from './src/types.js' + +export { + matchPathPattern, + isPathOwnedBy, + resolveContainerForPath, + normalizePathname, + parseSearch, + serializeSearch, + splitHref, +} from './src/path-match.js' + +export { + pathToScheme, + initialPathFromQueryItems, + hrefFromPathAndSearch, +} from './src/scheme.js' + +export { + GlobalStackMirror, + getGlobalStackMirror, + __setGlobalStackMirrorForTests, +} from './src/global-stack-mirror.js' +export type { StackMirrorListener } from './src/global-stack-mirror.js' + +export { createInMemoryStackProtocol } from './src/in-memory-stack.js' +export type { InMemoryStackOptions } from './src/in-memory-stack.js' + +export { createCompositeHistory } from './src/composite-history.js' +export type { CompositeHistoryOptions } from './src/composite-history.js' + +export { createPipeStackProtocol } from './src/protocol-client.js' + +export { + createSparklingHistory, + resolveContainerIdentity, +} from './src/create-router.js' +export type { + CreateSparklingHistoryOptions, + SparklingRouterRuntime, +} from './src/create-router.js' diff --git a/packages/sparkling-router/jest.config.ts b/packages/sparkling-router/jest.config.ts new file mode 100644 index 00000000..890cafff --- /dev/null +++ b/packages/sparkling-router/jest.config.ts @@ -0,0 +1,24 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import type { Config } from 'jest'; + +const config: Config = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/src'], + testMatch: ['**/__tests__/**/*.test.ts'], + moduleFileExtensions: ['ts', 'js', 'json'], + moduleNameMapper: { + '^(\\.{1,2}/.*)\\.js$': '$1', + }, + collectCoverageFrom: [ + 'src/**/*.ts', + '!src/**/*.d.ts', + '!src/**/__tests__/**', + ], + coverageReporters: ['text', 'lcov', 'html'], + verbose: false, +}; + +export default config; diff --git a/packages/sparkling-router/package.json b/packages/sparkling-router/package.json new file mode 100644 index 00000000..81d4eb68 --- /dev/null +++ b/packages/sparkling-router/package.json @@ -0,0 +1,50 @@ +{ + "name": "sparkling-router", + "version": "2.1.0-rc.12", + "description": "URL-first dual-layer router for Sparkling — TanStack core + native stack protocol", + "homepage": "https://tiktok.github.io/sparkling/", + "repository": { + "type": "git", + "url": "https://github.com/tiktok/sparkling", + "directory": "packages/sparkling-router" + }, + "license": "Apache-2.0", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist", + "src", + "index.ts", + "README.md" + ], + "scripts": { + "build": "tsc", + "test": "jest", + "test:coverage": "jest --coverage" + }, + "dependencies": { + "@tanstack/history": "1.161.6", + "sparkling-navigation": "workspace:*" + }, + "peerDependencies": { + "@tanstack/react-router": "^1.170.0" + }, + "peerDependenciesMeta": { + "@tanstack/react-router": { + "optional": true + } + }, + "devDependencies": { + "@types/jest": "^29.5.12", + "jest": "^29.7.0", + "ts-jest": "^29.1.2", + "typescript": "^5.8.3" + } +} diff --git a/packages/sparkling-router/src/__tests__/composite-history.test.ts b/packages/sparkling-router/src/__tests__/composite-history.test.ts new file mode 100644 index 00000000..f5f51280 --- /dev/null +++ b/packages/sparkling-router/src/__tests__/composite-history.test.ts @@ -0,0 +1,113 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { createCompositeHistory } from '../composite-history' +import { __setGlobalStackMirrorForTests, GlobalStackMirror } from '../global-stack-mirror' +import { createInMemoryStackProtocol } from '../in-memory-stack' +import type { RouteManifest } from '../types' + +const manifest: RouteManifest = { + version: '1', + scheme: { base: 'hybrid://lynxview_page' }, + containers: [ + { + bundle: 'home', + presentation: 'push', + routes: [{ path: '/' }], + }, + { + bundle: 'feed', + presentation: 'push', + routes: [{ path: '/feed' }, { path: '/feed/$postId' }], + }, + ], +} + +describe('createCompositeHistory', () => { + beforeEach(() => { + __setGlobalStackMirrorForTests(new GlobalStackMirror()) + }) + + afterEach(() => { + __setGlobalStackMirrorForTests(undefined) + }) + + it('keeps in-container navigations soft (memory)', async () => { + const stack = createInMemoryStackProtocol({ + manifest, + initial: { path: '/feed' }, + }) + const history = createCompositeHistory({ + container: { + bundle: 'feed', + ownedRoutes: ['/feed', '/feed/$postId'], + presentation: 'push', + }, + manifest, + stack, + initialEntries: ['/feed'], + }) + + const before = (await stack.getState()).entries.length + history.push('/feed/42') + expect(history.location.pathname).toBe('/feed/42') + const after = (await stack.getState()).entries.length + expect(after).toBe(before) + + history.destroy() + }) + + it('translates cross-boundary push into stack protocol', async () => { + const stack = createInMemoryStackProtocol({ + manifest, + initial: { path: '/' }, + }) + const history = createCompositeHistory({ + container: { + bundle: 'home', + ownedRoutes: ['/'], + presentation: 'push', + }, + manifest, + stack, + initialEntries: ['/'], + }) + + history.push('/feed') + // allow microtask for async protocol + await new Promise((r) => setTimeout(r, 0)) + const state = await stack.getState() + expect(state.entries.map((e) => e.bundle)).toEqual(['home', 'feed']) + + history.destroy() + }) + + it('pops native stack when soft history is exhausted', async () => { + const stack = createInMemoryStackProtocol({ + manifest, + initial: { path: '/' }, + }) + await stack.push({ path: '/feed' }) + + const history = createCompositeHistory({ + container: { + bundle: 'feed', + ownedRoutes: ['/feed', '/feed/$postId'], + presentation: 'push', + }, + manifest, + stack, + initialEntries: ['/feed'], + }) + + expect(history.canGoBack()).toBe(true) + history.back() + await new Promise((r) => setTimeout(r, 0)) + const state = await stack.getState() + expect(state.entries).toHaveLength(1) + expect(state.entries[0]!.bundle).toBe('home') + + history.destroy() + }) +}) diff --git a/packages/sparkling-router/src/__tests__/global-stack-mirror.test.ts b/packages/sparkling-router/src/__tests__/global-stack-mirror.test.ts new file mode 100644 index 00000000..3443e77a --- /dev/null +++ b/packages/sparkling-router/src/__tests__/global-stack-mirror.test.ts @@ -0,0 +1,38 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { GlobalStackMirror } from '../global-stack-mirror' + +describe('GlobalStackMirror', () => { + it('ignores stale versions and notifies listeners', () => { + const mirror = new GlobalStackMirror() + const listener = jest.fn() + mirror.subscribe(listener) + + mirror.apply({ + state: { + version: 2, + entries: [ + { + id: 'a', + path: '/', + search: {}, + bundle: 'home', + presentation: 'push', + }, + ], + }, + reason: 'push', + }) + expect(mirror.getState().version).toBe(2) + expect(listener).toHaveBeenCalledTimes(1) + + mirror.apply({ + state: { version: 1, entries: [] }, + reason: 'pop', + }) + expect(mirror.getState().version).toBe(2) + expect(listener).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/sparkling-router/src/__tests__/path-match.test.ts b/packages/sparkling-router/src/__tests__/path-match.test.ts new file mode 100644 index 00000000..e1d21e24 --- /dev/null +++ b/packages/sparkling-router/src/__tests__/path-match.test.ts @@ -0,0 +1,37 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { + isPathOwnedBy, + matchPathPattern, + normalizePathname, + resolveContainerForPath, +} from '../path-match' + +describe('path-match', () => { + it('normalizes pathnames', () => { + expect(normalizePathname('feed')).toBe('/feed') + expect(normalizePathname('/feed/')).toBe('/feed') + expect(normalizePathname('')).toBe('/') + }) + + it('matches static and dynamic segments', () => { + expect(matchPathPattern('/feed', '/feed')).toBe(true) + expect(matchPathPattern('/feed/$postId', '/feed/42')).toBe(true) + expect(matchPathPattern('/feed/$postId', '/feed')).toBe(false) + expect(matchPathPattern('/user/$id', '/feed/1')).toBe(false) + }) + + it('resolves the longest matching container', () => { + const containers = [ + { bundle: 'home', routes: [{ path: '/' }] }, + { bundle: 'feed', routes: [{ path: '/feed' }, { path: '/feed/$postId' }] }, + { bundle: 'user', routes: [{ path: '/user/$id' }] }, + ] + expect(resolveContainerForPath('/feed/9', containers)?.bundle).toBe('feed') + expect(resolveContainerForPath('/user/1', containers)?.bundle).toBe('user') + expect(resolveContainerForPath('/missing', containers)).toBeUndefined() + expect(isPathOwnedBy('/feed/1', ['/feed/$postId'])).toBe(true) + }) +}) diff --git a/packages/sparkling-router/src/__tests__/scheme.test.ts b/packages/sparkling-router/src/__tests__/scheme.test.ts new file mode 100644 index 00000000..38220ec4 --- /dev/null +++ b/packages/sparkling-router/src/__tests__/scheme.test.ts @@ -0,0 +1,41 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { initialPathFromQueryItems, pathToScheme } from '../scheme' +import type { RouteManifest } from '../types' + +const manifest: RouteManifest = { + version: '1', + scheme: { base: 'hybrid://lynxview_page' }, + containers: [ + { + bundle: 'home.lynx.bundle', + presentation: 'push', + routes: [{ path: '/' }], + }, + { + bundle: 'feed.lynx.bundle', + presentation: 'push', + routes: [{ path: '/feed' }, { path: '/feed/$postId' }], + containerOptions: { hide_nav_bar: '1' }, + }, + ], +} + +describe('scheme', () => { + it('translates path + search into a hybrid scheme', () => { + const result = pathToScheme('/feed/7?tab=hot', manifest) + expect(result).toBeDefined() + expect(result!.container.bundle).toBe('feed.lynx.bundle') + expect(result!.scheme).toContain('bundle=feed.lynx.bundle') + expect(result!.scheme).toContain('__path=%2Ffeed%2F7') + expect(result!.scheme).toContain('tab=hot') + expect(result!.scheme).toContain('hide_nav_bar=1') + }) + + it('reads __path from queryItems', () => { + expect(initialPathFromQueryItems({ __path: '/feed/1' })).toBe('/feed/1') + expect(initialPathFromQueryItems({})).toBe('/') + }) +}) diff --git a/packages/sparkling-router/src/composite-history.ts b/packages/sparkling-router/src/composite-history.ts new file mode 100644 index 00000000..3056fc11 --- /dev/null +++ b/packages/sparkling-router/src/composite-history.ts @@ -0,0 +1,216 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { + createMemoryHistory, + type NavigateOptions, + type RouterHistory, +} from '@tanstack/history' +import { getGlobalStackMirror } from './global-stack-mirror.js' +import { isPathOwnedBy, parseSearch, splitHref } from './path-match.js' +import type { + ContainerIdentity, + NativeStackProtocol, + RouteManifest, + StackChangedEvent, +} from './types.js' + +export interface CompositeHistoryOptions { + /** Identity of the container that owns this history instance. */ + container: ContainerIdentity + /** Global route manifest used to classify soft vs hard navigation. */ + manifest: RouteManifest + /** Stack protocol implementation (pipe-backed or in-memory). */ + stack: NativeStackProtocol + /** Soft-nav bootstrap location (usually from `__path` queryItems). */ + initialEntries?: string[] + /** + * Gesture / hardware back = native pop of the whole container (iOS-like). + * Soft `history.back()` still pops memory first, then native when at subtree root. + * Default: true (RFC §10 Q1 tendency). + */ + gesturePopsContainer?: boolean +} + +/** + * TanStack `RouterHistory` that delegates in-container navigations to memory + * history (soft) and cross-boundary navigations to the native stack protocol (hard). + */ +export function createCompositeHistory(options: CompositeHistoryOptions): RouterHistory { + const ownedRoutes = + options.container.ownedRoutes.length > 0 + ? options.container.ownedRoutes + : options.manifest.containers.find((c) => c.bundle === options.container.bundle)?.routes.map( + (r) => r.path, + ) ?? [] + + const memory = createMemoryHistory({ + initialEntries: options.initialEntries?.length ? options.initialEntries : ['/'], + }) + + const subscribers = new Set< + (opts: { location: RouterHistory['location']; action: any }) => void + >() + + let destroyed = false + let syncTimer: ReturnType | undefined + + const isSoft = (href: string): boolean => { + const { pathname } = splitHref(href) + return isPathOwnedBy(pathname, ownedRoutes) + } + + const scheduleSyncOwnLocation = () => { + if (syncTimer) { + clearTimeout(syncTimer) + } + // Coalesce rapid soft navigations into one native write-back. + syncTimer = setTimeout(() => { + if (destroyed) { + return + } + const { pathname, search } = splitHref(memory.location.href) + options.stack.syncOwnLocation({ + path: pathname, + search: parseSearch(search), + }) + }, 0) + } + + const notify = (action: { type: string; index?: number }) => { + const payload = { location: memory.location, action } + for (const cb of subscribers) { + cb(payload) + } + } + + // Keep CompositeHistory subscribers in sync with soft memory changes. + const unsubMemory = memory.subscribe(({ action }) => { + notify(action) + scheduleSyncOwnLocation() + }) + + const unsubMirror = getGlobalStackMirror().subscribe((event: StackChangedEvent) => { + if (destroyed) { + return + } + // Native-initiated pops (gesture / back button) — converge local state. + if ( + event.reason === 'user-back-gesture' || + event.reason === 'user-back-button' || + event.reason === 'system' || + event.reason === 'pop' || + event.reason === 'reset' + ) { + const top = event.state.entries[event.state.entries.length - 1] + if (!top || top.bundle !== options.container.bundle) { + // This container is no longer on top (or gone). Soft history stays; + // the Lynx runtime will be torn down with the native container. + return + } + const targetHref = `${top.path}${serializeSearchLocal(top.search)}` + if (memory.location.href !== targetHref && isSoft(targetHref)) { + memory.replace(targetHref) + } + } + }) + + const history: RouterHistory = { + get location() { + return memory.location + }, + get length() { + return memory.length + }, + subscribers, + subscribe(cb) { + subscribers.add(cb) + return () => { + subscribers.delete(cb) + } + }, + push(path: string, state?: any, navigateOpts?: NavigateOptions) { + if (isSoft(path)) { + memory.push(path, state, navigateOpts) + return + } + const { pathname, search } = splitHref(path) + void options.stack.push({ + path: pathname, + search: parseSearch(search), + presentation: options.manifest.containers.find((c) => + isPathOwnedBy( + pathname, + c.routes.map((r) => r.path), + ), + )?.presentation, + }) + }, + replace(path: string, state?: any, navigateOpts?: NavigateOptions) { + if (isSoft(path)) { + memory.replace(path, state, navigateOpts) + return + } + const { pathname, search } = splitHref(path) + void options.stack.replace({ + path: pathname, + search: parseSearch(search), + }) + }, + go(index: number, navigateOpts?: NavigateOptions) { + memory.go(index, navigateOpts) + }, + back(navigateOpts?: NavigateOptions) { + // Soft back while memory has depth; otherwise hard pop. + if (memory.canGoBack()) { + memory.back(navigateOpts) + return + } + void options.stack.pop({ animated: true }) + }, + forward(navigateOpts?: NavigateOptions) { + memory.forward(navigateOpts) + }, + canGoBack() { + return memory.canGoBack() || getGlobalStackMirror().getState().entries.length > 1 + }, + createHref(href: string) { + return memory.createHref(href) + }, + block(blocker) { + return memory.block(blocker) + }, + flush() { + memory.flush() + }, + destroy() { + destroyed = true + if (syncTimer) { + clearTimeout(syncTimer) + } + unsubMemory() + unsubMirror() + subscribers.clear() + memory.destroy() + }, + notify(action) { + memory.notify(action) + }, + } + + // Expose policy for tests / future gesture bridge wiring. + ;(history as RouterHistory & { __gesturePopsContainer?: boolean }).__gesturePopsContainer = + options.gesturePopsContainer !== false + + return history +} + +function serializeSearchLocal(search: Record): string { + const params = new URLSearchParams() + for (const [key, value] of Object.entries(search)) { + params.append(key, value) + } + const encoded = params.toString().replace(/\+/g, '%20') + return encoded ? `?${encoded}` : '' +} diff --git a/packages/sparkling-router/src/create-router.ts b/packages/sparkling-router/src/create-router.ts new file mode 100644 index 00000000..a5522e74 --- /dev/null +++ b/packages/sparkling-router/src/create-router.ts @@ -0,0 +1,100 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import type { RouterHistory } from '@tanstack/history' +import { createCompositeHistory, type CompositeHistoryOptions } from './composite-history.js' +import { getGlobalStackMirror } from './global-stack-mirror.js' +import { createInMemoryStackProtocol } from './in-memory-stack.js' +import { createPipeStackProtocol } from './protocol-client.js' +import { initialPathFromQueryItems } from './scheme.js' +import type { + ContainerIdentity, + NativeStackProtocol, + RouteManifest, +} from './types.js' + +export interface CreateSparklingHistoryOptions { + manifest: RouteManifest + container: ContainerIdentity + /** Inject a custom stack protocol (tests / demos). Defaults to pipe-backed. */ + stack?: NativeStackProtocol + /** Soft-nav initial entries. Defaults to [`__path` from queryItems, `/`]. */ + initialEntries?: string[] + queryItems?: Record + gesturePopsContainer?: boolean + /** + * Use the in-memory stack protocol instead of native pipe. + * Useful for S0 soft-nav demos and unit tests. + */ + memoryStack?: boolean +} + +export interface SparklingRouterRuntime { + history: RouterHistory + stack: NativeStackProtocol + mirror: ReturnType + container: ContainerIdentity + manifest: RouteManifest +} + +/** + * Wire CompositeHistory + stack protocol for one container runtime. + * Callers pass the result into TanStack `createRouter({ history, routeTree, isServer: false })`. + */ +export function createSparklingHistory( + options: CreateSparklingHistoryOptions, +): SparklingRouterRuntime { + const stack = + options.stack ?? + (options.memoryStack + ? createInMemoryStackProtocol({ + manifest: options.manifest, + initial: { path: options.container.ownedRoutes[0] ?? '/' }, + }) + : createPipeStackProtocol()) + + const bootstrap = + options.initialEntries ?? + [initialPathFromQueryItems(options.queryItems, options.container.ownedRoutes[0] ?? '/')] + + const historyOptions: CompositeHistoryOptions = { + container: options.container, + manifest: options.manifest, + stack, + initialEntries: bootstrap, + gesturePopsContainer: options.gesturePopsContainer, + } + + const history = createCompositeHistory(historyOptions) + + return { + history, + stack, + mirror: getGlobalStackMirror(), + container: options.container, + manifest: options.manifest, + } +} + +/** + * Resolve the current container identity from a manifest + bundle name. + */ +export function resolveContainerIdentity( + manifest: RouteManifest, + bundle: string, +): ContainerIdentity { + const found = manifest.containers.find((c) => c.bundle === bundle) + if (!found) { + return { + bundle, + ownedRoutes: ['/'], + presentation: 'push', + } + } + return { + bundle: found.bundle, + ownedRoutes: found.routes.map((r) => r.path), + presentation: found.presentation, + } +} diff --git a/packages/sparkling-router/src/global-stack-mirror.ts b/packages/sparkling-router/src/global-stack-mirror.ts new file mode 100644 index 00000000..f7b24e07 --- /dev/null +++ b/packages/sparkling-router/src/global-stack-mirror.ts @@ -0,0 +1,65 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import type { StackChangedEvent, StackState } from './types.js' + +export type StackMirrorListener = (event: StackChangedEvent) => void + +const EMPTY_STATE: StackState = { version: 0, entries: [] } + +/** + * Read-only mirror of the native navigation stack. + * Populated by stack-changed events; never mutated by local soft navigation. + */ +export class GlobalStackMirror { + private state: StackState = EMPTY_STATE + private readonly listeners = new Set() + + getState(): StackState { + return this.state + } + + subscribe(listener: StackMirrorListener): () => void { + this.listeners.add(listener) + return () => { + this.listeners.delete(listener) + } + } + + /** Apply a native (or test) stack-changed event. Ignores stale versions. */ + apply(event: StackChangedEvent): void { + if (event.state.version < this.state.version) { + return + } + this.state = { + version: event.state.version, + entries: event.state.entries.map((entry) => ({ + ...entry, + search: { ...entry.search }, + })), + } + for (const listener of this.listeners) { + listener(event) + } + } + + reset(): void { + this.state = EMPTY_STATE + this.listeners.clear() + } +} + +let sharedMirror: GlobalStackMirror | undefined + +export function getGlobalStackMirror(): GlobalStackMirror { + if (!sharedMirror) { + sharedMirror = new GlobalStackMirror() + } + return sharedMirror +} + +/** Test helper — replace the process-wide mirror. */ +export function __setGlobalStackMirrorForTests(mirror?: GlobalStackMirror): void { + sharedMirror = mirror +} diff --git a/packages/sparkling-router/src/in-memory-stack.ts b/packages/sparkling-router/src/in-memory-stack.ts new file mode 100644 index 00000000..2e9edb2d --- /dev/null +++ b/packages/sparkling-router/src/in-memory-stack.ts @@ -0,0 +1,170 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { getGlobalStackMirror } from './global-stack-mirror.js' +import { hrefFromPathAndSearch, pathToScheme } from './scheme.js' +import type { + NativeStackProtocol, + NavResult, + RouteManifest, + StackChangeReason, + StackEntry, + StackState, +} from './types.js' + +export interface InMemoryStackOptions { + manifest: RouteManifest + /** Seed the stack with an initial entry (defaults to first container `/`). */ + initial?: { path: string; search?: Record } + onChange?: (state: StackState, reason: StackChangeReason) => void +} + +/** + * JS-only stack protocol for unit tests and soft-nav playgrounds. + * Mirrors native semantics closely enough to exercise CompositeHistory. + */ +export function createInMemoryStackProtocol( + options: InMemoryStackOptions, +): NativeStackProtocol { + let version = 0 + const entries: StackEntry[] = [] + const mirror = getGlobalStackMirror() + + const emit = (reason: StackChangeReason, result?: { forEntryId: string; value: unknown }) => { + version += 1 + const state: StackState = { + version, + entries: entries.map((e) => ({ ...e, search: { ...e.search } })), + } + mirror.apply({ state, reason, result }) + options.onChange?.(state, reason) + } + + const resolveEntry = ( + path: string, + search: Record | undefined, + presentation: 'push' | 'modal' | undefined, + ): StackEntry | undefined => { + const translated = pathToScheme(hrefFromPathAndSearch(path, search), options.manifest) + if (!translated) { + return undefined + } + return { + id: `mem-${Math.random().toString(36).slice(2, 10)}`, + path: translated.path, + search: translated.search, + bundle: translated.container.bundle, + presentation: presentation ?? translated.container.presentation, + } + } + + if (options.initial) { + const seed = resolveEntry(options.initial.path, options.initial.search, 'push') + if (seed) { + entries.push(seed) + emit('reset') + } + } else if (options.manifest.containers[0]) { + const first = options.manifest.containers[0] + const path = first.routes[0]?.path ?? '/' + const seed = resolveEntry(path, undefined, first.presentation) + if (seed) { + entries.push(seed) + emit('reset') + } + } + + const ok = (entryId: string): NavResult => ({ code: 1, entryId }) + const fail = (msg: string): NavResult => ({ code: 0, msg }) + + return { + async push(req) { + const entry = resolveEntry(req.path, req.search, req.presentation) + if (!entry) { + return fail(`No container owns path: ${req.path}`) + } + entries.push(entry) + emit('push') + return ok(entry.id) + }, + + async pop(req) { + if (entries.length <= 1) { + return fail('Cannot pop root stack entry') + } + const removed = entries.pop()! + const below = entries[entries.length - 1]! + emit( + 'pop', + req?.result !== undefined + ? { forEntryId: below.id, value: req.result } + : undefined, + ) + return ok(removed.id) + }, + + async popTo(req) { + const index = entries.findIndex((e) => e.id === req.entryId) + if (index < 0) { + return fail(`Unknown entryId: ${req.entryId}`) + } + if (index === entries.length - 1) { + return ok(req.entryId) + } + entries.splice(index + 1) + emit('pop') + return ok(req.entryId) + }, + + async replace(req) { + if (entries.length === 0) { + return fail('Stack is empty') + } + const entry = resolveEntry(req.path, req.search, entries[entries.length - 1]?.presentation) + if (!entry) { + return fail(`No container owns path: ${req.path}`) + } + const prev = entries[entries.length - 1]! + entry.id = prev.id + entries[entries.length - 1] = entry + emit('replace') + return ok(entry.id) + }, + + async reset(req) { + const next: StackEntry[] = [] + for (const item of req.entries) { + const entry = resolveEntry(item.path, item.search, item.presentation) + if (!entry) { + return fail(`No container owns path: ${item.path}`) + } + next.push(entry) + } + entries.splice(0, entries.length, ...next) + emit('reset') + return ok(entries[entries.length - 1]?.id ?? '') + }, + + async getState() { + return { + version, + entries: entries.map((e) => ({ ...e, search: { ...e.search } })), + } + }, + + async prefetch() { + return { code: 1, msg: 'ok' } + }, + + syncOwnLocation(req) { + if (entries.length === 0) { + return + } + const top = entries[entries.length - 1]! + top.path = req.path + top.search = { ...req.search } + emit('sync') + }, + } +} diff --git a/packages/sparkling-router/src/path-match.ts b/packages/sparkling-router/src/path-match.ts new file mode 100644 index 00000000..f6f8b03e --- /dev/null +++ b/packages/sparkling-router/src/path-match.ts @@ -0,0 +1,132 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +/** + * Match a concrete pathname against a TanStack-style path pattern. + * Supports `$param` dynamic segments and trailing optional `/`. + */ +export function matchPathPattern(pattern: string, pathname: string): boolean { + const normalizedPattern = normalizePathname(pattern) + const normalizedPath = normalizePathname(pathname) + + if (normalizedPattern === normalizedPath) { + return true + } + + const patternParts = splitPath(normalizedPattern) + const pathParts = splitPath(normalizedPath) + + if (patternParts.length !== pathParts.length) { + return false + } + + for (let i = 0; i < patternParts.length; i++) { + const expected = patternParts[i]! + const actual = pathParts[i]! + if (expected.startsWith('$')) { + if (!actual) { + return false + } + continue + } + if (expected !== actual) { + return false + } + } + + return true +} + +/** True when pathname is owned by any of the given route patterns. */ +export function isPathOwnedBy(pathname: string, routes: string[]): boolean { + return routes.some((route) => matchPathPattern(route, pathname)) +} + +/** + * Longest-prefix container resolution for hard navigation. + * Prefers exact matches, then longest matching pattern. + */ +export function resolveContainerForPath }>( + pathname: string, + containers: T[], +): T | undefined { + let best: { container: T; score: number } | undefined + + for (const container of containers) { + for (const route of container.routes) { + if (!matchPathPattern(route.path, pathname)) { + continue + } + const score = splitPath(normalizePathname(route.path)).length + if (!best || score > best.score) { + best = { container, score } + } + } + } + + return best?.container +} + +export function normalizePathname(pathname: string): string { + if (!pathname) { + return '/' + } + let value = pathname.trim() + if (!value.startsWith('/')) { + value = `/${value}` + } + if (value.length > 1 && value.endsWith('/')) { + value = value.slice(0, -1) + } + return value +} + +export function splitPath(pathname: string): string[] { + return normalizePathname(pathname).split('/').filter(Boolean) +} + +export function parseSearch(search: string): Record { + const result: Record = {} + if (!search) { + return result + } + const raw = search.startsWith('?') ? search.slice(1) : search + if (!raw) { + return result + } + const params = new URLSearchParams(raw) + params.forEach((value, key) => { + result[key] = value + }) + return result +} + +export function serializeSearch(search: Record | undefined): string { + if (!search) { + return '' + } + const params = new URLSearchParams() + for (const [key, value] of Object.entries(search)) { + if (value === undefined || value === null) { + continue + } + params.append(key, String(value)) + } + const encoded = params.toString().replace(/\+/g, '%20') + return encoded ? `?${encoded}` : '' +} + +export function splitHref(href: string): { pathname: string; search: string; hash: string } { + const hashIndex = href.indexOf('#') + const withoutHash = hashIndex >= 0 ? href.slice(0, hashIndex) : href + const hash = hashIndex >= 0 ? href.slice(hashIndex) : '' + const searchIndex = withoutHash.indexOf('?') + const pathname = searchIndex >= 0 ? withoutHash.slice(0, searchIndex) : withoutHash + const search = searchIndex >= 0 ? withoutHash.slice(searchIndex) : '' + return { + pathname: normalizePathname(pathname || '/'), + search, + hash, + } +} diff --git a/packages/sparkling-router/src/protocol-client.ts b/packages/sparkling-router/src/protocol-client.ts new file mode 100644 index 00000000..19799703 --- /dev/null +++ b/packages/sparkling-router/src/protocol-client.ts @@ -0,0 +1,64 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { + getState as navGetState, + pop as navPop, + popTo as navPopTo, + prefetch as navPrefetch, + push as navPush, + replace as navReplace, + reset as navReset, + subscribeStackChanged, + syncOwnLocation as navSyncOwnLocation, + type NativeStackProtocol as NavProtocol, + type StackChangedEvent as NavStackChangedEvent, + type StackState as NavStackState, +} from 'sparkling-navigation' +import { getGlobalStackMirror } from './global-stack-mirror.js' +import type { NativeStackProtocol, StackChangedEvent, StackState } from './types.js' + +/** + * Pipe-backed NativeStackProtocol that talks to sparkling-navigation stack methods. + * Also wires stack-changed events into GlobalStackMirror. + */ +export function createPipeStackProtocol(): NativeStackProtocol { + let unsubscribe: (() => void) | undefined + + const ensureSubscription = () => { + if (unsubscribe) { + return + } + try { + unsubscribe = subscribeStackChanged((event: NavStackChangedEvent) => { + getGlobalStackMirror().apply(event as StackChangedEvent) + }) + } catch { + // Lynx GlobalEventEmitter unavailable (unit tests / web without polyfill). + } + } + + ensureSubscription() + + const protocol: NativeStackProtocol = { + push: (req) => navPush(req), + pop: (req) => navPop(req), + popTo: (req) => navPopTo(req), + replace: (req) => navReplace(req), + reset: (req) => navReset(req), + getState: async () => { + const state = (await navGetState()) as NavStackState + return state as StackState + }, + prefetch: (req) => navPrefetch(req), + syncOwnLocation: (req) => { + navSyncOwnLocation(req) + }, + } + + // Retain type alignment with navigation package protocol. + void (protocol as unknown as NavProtocol) + + return protocol +} diff --git a/packages/sparkling-router/src/scheme.ts b/packages/sparkling-router/src/scheme.ts new file mode 100644 index 00000000..571fd7af --- /dev/null +++ b/packages/sparkling-router/src/scheme.ts @@ -0,0 +1,92 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import { + DEFAULT_SCHEME_BASE, + PATH_QUERY_KEY, + type RouteManifest, + type RouteManifestContainer, +} from './types.js' +import { + normalizePathname, + resolveContainerForPath, + serializeSearch, + splitHref, +} from './path-match.js' + +export interface SchemeTranslation { + scheme: string + container: RouteManifestContainer + path: string + search: Record +} + +/** + * Translate a logical path (+ search) into a Sparkling hybrid scheme URL + * using the route manifest. Deep soft routes are carried as `__path`. + */ +export function pathToScheme( + href: string, + manifest: RouteManifest, +): SchemeTranslation | undefined { + const { pathname, search } = splitHref(href) + const container = resolveContainerForPath(pathname, manifest.containers) + if (!container) { + return undefined + } + + const searchRecord: Record = {} + new URLSearchParams(search).forEach((value, key) => { + searchRecord[key] = value + }) + const base = (manifest.scheme?.base || DEFAULT_SCHEME_BASE).replace(/[?&]+$/, '') + const params = new URLSearchParams() + params.set('bundle', container.bundle) + params.set(PATH_QUERY_KEY, pathname) + + for (const [key, value] of Object.entries(searchRecord)) { + if (key === 'bundle' || key === 'url' || key === PATH_QUERY_KEY) { + continue + } + params.append(key, value) + } + + if (container.containerOptions) { + for (const [key, value] of Object.entries(container.containerOptions)) { + if (!params.has(key)) { + params.set(key, value) + } + } + } + + const query = params.toString().replace(/\+/g, '%20') + return { + scheme: `${base}?${query}`, + container, + path: pathname, + search: searchRecord, + } +} + +/** Read the soft-nav bootstrap path from scheme / globalProps queryItems. */ +export function initialPathFromQueryItems( + queryItems: Record | undefined, + fallback = '/', +): string { + if (!queryItems) { + return normalizePathname(fallback) + } + const fromPath = queryItems[PATH_QUERY_KEY] || queryItems.path + if (fromPath && typeof fromPath === 'string' && fromPath.trim()) { + return normalizePathname(fromPath) + } + return normalizePathname(fallback) +} + +export function hrefFromPathAndSearch( + path: string, + search?: Record, +): string { + return `${normalizePathname(path)}${serializeSearch(search)}` +} diff --git a/packages/sparkling-router/src/types.ts b/packages/sparkling-router/src/types.ts new file mode 100644 index 00000000..45ff2bcd --- /dev/null +++ b/packages/sparkling-router/src/types.ts @@ -0,0 +1,111 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +/** One hard-navigation entry = one native container. */ +export interface StackEntry { + /** Native-generated stable ID (containerID). */ + id: string + /** Current URL path for this container (may include soft-nav deep path). */ + path: string + search: Record + /** Bundle loaded by this container (from manifest). */ + bundle: string + presentation: 'push' | 'modal' +} + +export interface StackState { + /** Monotonic version for event dedupe / ordering. */ + version: number + entries: StackEntry[] +} + +export type NavResult = + | { code: 1; entryId: string; msg?: string } + | { code: number; msg: string; entryId?: string } + +export type StackChangeReason = + | 'push' + | 'pop' + | 'replace' + | 'reset' + | 'user-back-gesture' + | 'user-back-button' + | 'system' + | 'sync' + +export interface StackChangedEvent { + state: StackState + reason: StackChangeReason + /** Pop result routed to the container that initiated the push. */ + result?: { forEntryId: string; value: unknown } +} + +export interface NativeStackProtocol { + push(req: { + path: string + search?: Record + presentation?: 'push' | 'modal' + usePrefetched?: boolean + animated?: boolean + }): Promise + + pop(req?: { result?: unknown; animated?: boolean }): Promise + popTo(req: { entryId: string; animated?: boolean }): Promise + replace(req: { + path: string + search?: Record + }): Promise + + /** Atomically declare the desired stack shape; native diffs to a min push/pop sequence. */ + reset(req: { + entries: Array<{ + path: string + search?: Record + presentation?: 'push' | 'modal' + }> + }): Promise + + getState(): Promise + + /** Pre-create container + preload bundle without presenting. TTL managed by native. */ + prefetch(req: { + path: string + search?: Record + }): Promise<{ code: number; msg?: string }> + + /** Soft-nav write-back so the stack mirror matches deep location. */ + syncOwnLocation(req: { + path: string + search: Record + }): void +} + +export interface RouteManifestContainer { + bundle: string + presentation: 'push' | 'modal' + /** Path prefixes / patterns owned by this container (may include `$param` segments). */ + routes: Array<{ path: string }> + /** Compiled into scheme query as container options. */ + containerOptions?: Record +} + +/** Serializable route inventory injected into every bundle. */ +export interface RouteManifest { + /** Skew reserved; v1 writes but does not consume. */ + version: string + scheme: { base: string } + containers: RouteManifestContainer[] +} + +export interface ContainerIdentity { + /** Bundle / entry name for the current container. */ + bundle: string + /** Paths owned by this container (from manifest). */ + ownedRoutes: string[] + presentation: 'push' | 'modal' +} + +export const DEFAULT_SCHEME_BASE = 'hybrid://lynxview_page' +export const STACK_CHANGED_EVENT = 'sparklingStackChanged' +export const PATH_QUERY_KEY = '__path' diff --git a/packages/sparkling-router/tsconfig.json b/packages/sparkling-router/tsconfig.json new file mode 100644 index 00000000..af935669 --- /dev/null +++ b/packages/sparkling-router/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": ".", + "noEmit": false, + "declaration": true, + "emitDeclarationOnly": false, + "module": "ES2020", + "target": "ES2020", + "moduleResolution": "node", + "lib": ["ES2020", "DOM"] + }, + "include": ["./src/**/*", "index.ts"], + "exclude": [ + "node_modules", + "dist", + "**/*.test.ts", + "**/*.spec.ts", + "**/__tests__/**" + ] +} diff --git a/packages/sparkling-sdk/ios/Sparkling/Sources/Application/Container/Router/SPKContainerRegistry.swift b/packages/sparkling-sdk/ios/Sparkling/Sources/Application/Container/Router/SPKContainerRegistry.swift new file mode 100644 index 00000000..c8e83c80 --- /dev/null +++ b/packages/sparkling-sdk/ios/Sparkling/Sources/Application/Container/Router/SPKContainerRegistry.swift @@ -0,0 +1,59 @@ +// Copyright 2026 The Sparkling Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import Foundation +import UIKit + +/// Weak-reference registry mapping `containerID` → live container instances. +/// +/// Required by stack protocol `popTo(entryId)` and result routing. Also fixes +/// the existing `close({ containerID })` path that currently ignores the ID +/// on both platforms (RFC appendix A.1). +@objcMembers +public final class SPKContainerRegistry: NSObject { + public static let shared = SPKContainerRegistry() + + private let lock = NSLock() + private var storage: [String: WeakBox] = [:] + + private final class WeakBox { + weak var value: (UIViewController & SPKContainerProtocol)? + init(_ value: UIViewController & SPKContainerProtocol) { + self.value = value + } + } + + public func register(_ container: UIViewController & SPKContainerProtocol) { + let id = container.containerID + guard !id.isEmpty else { return } + lock.lock() + defer { lock.unlock() } + storage[id] = WeakBox(container) + compactLocked() + } + + public func unregister(containerID: String) { + lock.lock() + defer { lock.unlock() } + storage.removeValue(forKey: containerID) + } + + public func container(forID containerID: String) -> (UIViewController & SPKContainerProtocol)? { + lock.lock() + defer { lock.unlock() } + compactLocked() + return storage[containerID]?.value + } + + public func allContainerIDs() -> [String] { + lock.lock() + defer { lock.unlock() } + compactLocked() + return Array(storage.keys) + } + + private func compactLocked() { + storage = storage.filter { $0.value.value != nil } + } +} diff --git a/packages/sparkling-sdk/ios/Sparkling/Sources/Application/Container/Router/SPKRouter.swift b/packages/sparkling-sdk/ios/Sparkling/Sources/Application/Container/Router/SPKRouter.swift index 299a2300..1b32a768 100644 --- a/packages/sparkling-sdk/ios/Sparkling/Sources/Application/Container/Router/SPKRouter.swift +++ b/packages/sparkling-sdk/ios/Sparkling/Sources/Application/Container/Router/SPKRouter.swift @@ -57,8 +57,14 @@ public class SPKRouter: NSObject { // We have to use topVC.children.last to get the navigationController. naviVC.pushViewController(container, animated: true) } else { - return (nil, false) + // Fallback: present when no navigation controller is available (modal path). + let nav = UINavigationController(rootViewController: container) + nav.modalPresentationStyle = .fullScreen + topVC.present(nav, animated: true) + SPKContainerRegistry.shared.register(container) + return (container, true) } + SPKContainerRegistry.shared.register(container) return (container, true) } return (nil, false) diff --git a/packages/sparkling-sdk/ios/Sparkling/Sources/Application/Container/Router/SPKStackCoordinator.swift b/packages/sparkling-sdk/ios/Sparkling/Sources/Application/Container/Router/SPKStackCoordinator.swift new file mode 100644 index 00000000..ce7f2e53 --- /dev/null +++ b/packages/sparkling-sdk/ios/Sparkling/Sources/Application/Container/Router/SPKStackCoordinator.swift @@ -0,0 +1,126 @@ +// Copyright 2026 The Sparkling Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import Foundation +import UIKit + +/// Minimal native stack state for the hard-navigation protocol (S4 skeleton). +/// +/// Tracks ordered container entries, broadcasts `sparklingStackChanged`, and +/// uses `SPKContainerRegistry` for id → instance lookup. Full push/pop wiring +/// into `SPKRouter` / modal present is completed as hosts adopt `StackRouterService`. +@objcMembers +public final class SPKStackCoordinator: NSObject { + public static let shared = SPKStackCoordinator() + + public struct Entry { + public let id: String + public var path: String + public var search: [String: String] + public var bundle: String + public var presentation: String + + public init(id: String, path: String, search: [String: String], bundle: String, presentation: String) { + self.id = id + self.path = path + self.search = search + self.bundle = bundle + self.presentation = presentation + } + + public func asDictionary() -> [String: Any] { + [ + "id": id, + "path": path, + "search": search, + "bundle": bundle, + "presentation": presentation, + ] + } + } + + private let lock = NSLock() + private var version: Int = 0 + private var entries: [Entry] = [] + + public func currentState() -> [String: Any] { + lock.lock() + defer { lock.unlock() } + return [ + "version": version, + "entries": entries.map { $0.asDictionary() }, + ] + } + + public func append(_ entry: Entry, reason: String = "push") { + lock.lock() + entries.append(entry) + version += 1 + let payload = snapshotLocked(reason: reason) + lock.unlock() + broadcast(payload) + } + + @discardableResult + public func pop(reason: String = "pop") -> Entry? { + lock.lock() + guard entries.count > 0 else { + lock.unlock() + return nil + } + let removed = entries.removeLast() + version += 1 + let payload = snapshotLocked(reason: reason) + lock.unlock() + broadcast(payload) + return removed + } + + public func syncOwnLocation(containerID: String, path: String, search: [String: String]) { + lock.lock() + guard let index = entries.firstIndex(where: { $0.id == containerID }) else { + lock.unlock() + return + } + entries[index].path = path + entries[index].search = search + version += 1 + let payload = snapshotLocked(reason: "sync") + lock.unlock() + broadcast(payload) + } + + public func noteUserBack(containerID: String, fromGesture: Bool) { + lock.lock() + if let index = entries.firstIndex(where: { $0.id == containerID }) { + entries.removeSubrange(index...) + } else if !entries.isEmpty { + entries.removeLast() + } + version += 1 + let reason = fromGesture ? "user-back-gesture" : "user-back-button" + let payload = snapshotLocked(reason: reason) + lock.unlock() + broadcast(payload) + } + + private func snapshotLocked(reason: String) -> [String: Any] { + [ + "state": [ + "version": version, + "entries": entries.map { $0.asDictionary() }, + ], + "reason": reason, + ] + } + + private func broadcast(_ payload: [String: Any]) { + DispatchQueue.main.async { + for id in SPKContainerRegistry.shared.allContainerIDs() { + guard let container = SPKContainerRegistry.shared.container(forID: id) else { continue } + container.send(event: SPKEvent.Stack.changed, params: payload, callback: nil) + } + } + } +} diff --git a/packages/sparkling-sdk/ios/Sparkling/Sources/Application/Container/SPKViewController.swift b/packages/sparkling-sdk/ios/Sparkling/Sources/Application/Container/SPKViewController.swift index 88c5ae27..1ec1f040 100644 --- a/packages/sparkling-sdk/ios/Sparkling/Sources/Application/Container/SPKViewController.swift +++ b/packages/sparkling-sdk/ios/Sparkling/Sources/Application/Container/SPKViewController.swift @@ -394,6 +394,9 @@ open class SPKViewController: UIViewController, SPKContainerProtocol { SPKEvent.Common.containerIdKey: self?.containerID ?? "", SPKEvent.Back.actionFromKey: SPKEvent.Back.actionTypeSwipe, ], callback: nil) + if let containerID = self?.containerID, !containerID.isEmpty { + SPKStackCoordinator.shared.noteUserBack(containerID: containerID, fromGesture: true) + } } self.navigationController?.interactivePopGestureRecognizer?.delegate = self.oldDelegate @@ -598,6 +601,10 @@ open class SPKViewController: UIViewController, SPKContainerProtocol { SPKEvent.Back.actionFromKey: SPKEvent.Back.actionTypeNavBarBackPress, ]) + if !self.containerID.isEmpty { + SPKStackCoordinator.shared.noteUserBack(containerID: self.containerID, fromGesture: false) + } + if self.navigationController?.viewControllers.count ?? 0 > 1 { //MARK: currently only support lynx self.navigationController?.popViewController(animated: true) diff --git a/packages/sparkling-sdk/ios/Sparkling/Sources/Application/Container/Utils/SPKEventDefines.swift b/packages/sparkling-sdk/ios/Sparkling/Sources/Application/Container/Utils/SPKEventDefines.swift index 47ce253a..cac7622f 100644 --- a/packages/sparkling-sdk/ios/Sparkling/Sources/Application/Container/Utils/SPKEventDefines.swift +++ b/packages/sparkling-sdk/ios/Sparkling/Sources/Application/Container/Utils/SPKEventDefines.swift @@ -23,6 +23,11 @@ public enum SPKEvent { static let actionTypeSwipe = "swipe" } + /// Hard-navigation stack protocol events (Sparkling Router). + enum Stack { + static let changed = "sparklingStackChanged" + } + // MARK: - Common Keys /// Contains commonly used parameter keys across different event types. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a974c7f2..a6dc8939 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -40,6 +40,49 @@ importers: specifier: ^5.8.3 version: 5.9.3 + examples/router-demo: + dependencies: + '@lynx-js/react': + specifier: ^0.116.2 + version: 0.116.5(@lynx-js/types@3.7.0)(@types/react@18.3.28) + '@tanstack/react-router': + specifier: 1.170.18 + version: 1.170.18(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + sparkling-router: + specifier: workspace:* + version: link:../../packages/sparkling-router + url-search-params-polyfill: + specifier: ^8.2.5 + version: 8.2.5 + devDependencies: + '@lynx-js/qrcode-rsbuild-plugin': + specifier: ^0.4.4 + version: 0.4.6 + '@lynx-js/react-rsbuild-plugin': + specifier: ^0.12.7 + version: 0.12.10(@lynx-js/lynx-core@0.1.3)(@lynx-js/react@0.116.5(@lynx-js/types@3.7.0)(@types/react@18.3.28))(tslib@2.8.1)(webpack@5.105.0(esbuild@0.27.7)) + '@lynx-js/rspeedy': + specifier: ^0.13.3 + version: 0.13.6(@rspack/core@1.7.11(@swc/helpers@0.5.21))(esbuild@0.27.7)(typescript@5.9.3)(webpack@5.105.0(esbuild@0.27.7)) + '@lynx-js/types': + specifier: ^3.7.0 + version: 3.7.0 + '@rsbuild/core': + specifier: 1.7.2 + version: 1.7.2 + '@tanstack/router-plugin': + specifier: 1.168.23 + version: 1.168.23(@rsbuild/core@1.7.2)(@rspack/core@1.7.11(@swc/helpers@0.5.21))(@tanstack/react-router@1.170.18(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(esbuild@0.27.7)(rollup@4.60.1)(vite@7.3.2(@types/node@25.6.0)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3))(webpack@5.105.0(esbuild@0.27.7)) + '@types/react': + specifier: ^18.3.20 + version: 18.3.28 + sparkling-router-plugin: + specifier: workspace:* + version: link:../../packages/sparkling-router-plugin + typescript: + specifier: ^5.8.3 + version: 5.9.3 + packages/create-sparkling-app: dependencies: '@clack/prompts': @@ -172,7 +215,7 @@ importers: version: 18.3.28 '@vitest/coverage-v8': specifier: ^3.1.2 - version: 3.2.4(vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.6.0)(jiti@2.6.1)(jsdom@26.1.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3)) + version: 3.2.4(vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.6.0)(jiti@2.7.0)(jsdom@26.1.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3)) jsdom: specifier: ^26.1.0 version: 26.1.0 @@ -190,7 +233,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.1.2 - version: 3.2.4(@types/debug@4.1.13)(@types/node@25.6.0)(jiti@2.6.1)(jsdom@26.1.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3) + version: 3.2.4(@types/debug@4.1.13)(@types/node@25.6.0)(jiti@2.7.0)(jsdom@26.1.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3) packages/sparkling-app-cli: dependencies: @@ -295,6 +338,53 @@ importers: specifier: ^10.9.2 version: 10.9.2(@types/node@22.19.17)(typescript@5.9.3) + packages/sparkling-router: + dependencies: + '@tanstack/history': + specifier: 1.161.6 + version: 1.161.6 + '@tanstack/react-router': + specifier: ^1.170.0 + version: 1.170.18(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + sparkling-navigation: + specifier: workspace:* + version: link:../methods/sparkling-navigation + devDependencies: + '@types/jest': + specifier: ^29.5.12 + version: 29.5.14 + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@25.6.0)(ts-node@10.9.2(@types/node@25.6.0)(typescript@5.9.3)) + ts-jest: + specifier: ^29.1.2 + version: 29.4.9(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@29.7.0)(jest@29.7.0(@types/node@25.6.0)(ts-node@10.9.2(@types/node@25.6.0)(typescript@5.9.3)))(typescript@5.9.3) + typescript: + specifier: ^5.8.3 + version: 5.9.3 + + packages/sparkling-router-plugin: + dependencies: + sparkling-router: + specifier: workspace:* + version: link:../sparkling-router + devDependencies: + '@types/jest': + specifier: ^29.5.12 + version: 29.5.14 + '@types/node': + specifier: ^22.10.0 + version: 22.19.17 + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@22.19.17)(ts-node@10.9.2(@types/node@22.19.17)(typescript@5.9.3)) + ts-jest: + specifier: ^29.1.2 + version: 29.4.9(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.19.17)(ts-node@10.9.2(@types/node@22.19.17)(typescript@5.9.3)))(typescript@5.9.3) + typescript: + specifier: ^5.8.3 + version: 5.9.3 + packages/sparkling-sdk: {} packages/sparkling-types: @@ -420,7 +510,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.18 - version: 4.1.4(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4)(jsdom@28.1.0)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3)) + version: 4.1.4(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4)(jsdom@28.1.0)(vite@7.3.2(@types/node@25.6.0)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3)) packages: @@ -1866,6 +1956,67 @@ packages: '@swc/helpers@0.5.21': resolution: {integrity: sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==} + '@tanstack/history@1.161.6': + resolution: {integrity: sha512-NaOGLRrddszbQj9upGat6HG/4TKvXLvu+osAIgfxPYA+eIvYKv8GKDJOrY2D3/U9MRnKfMWD7bU4jeD4xmqyIg==} + engines: {node: '>=20.19'} + + '@tanstack/history@1.162.0': + resolution: {integrity: sha512-79pf/RkhteYZTRgcR4F9kbk84P2N8rugQJswxfIqovlbRiT3yI7eBE+5QorIrZaOKktsgzRlXh1l/du/xpl4iA==} + engines: {node: '>=20.19'} + + '@tanstack/react-router@1.170.18': + resolution: {integrity: sha512-wpbGYZEp/fmz1q4bn7BD8VZ+/VZ7GBqSJv5V969pU+chP8y7dquWDmKTFMohvUegb9lg12m1uPVvD6kB2wORvQ==} + engines: {node: '>=20.19'} + peerDependencies: + react: '>=18.0.0 || >=19.0.0' + react-dom: '>=18.0.0 || >=19.0.0' + + '@tanstack/react-store@0.9.3': + resolution: {integrity: sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/router-core@1.171.15': + resolution: {integrity: sha512-IILCDcLaItMZQ2jEmCABHY1Nhjjn5XUvwpQp3e4Nmu+vfg0BgYFuu/QASz2SwE2ZNbVMrvt8X/wxa+Gg5aErxA==} + engines: {node: '>=20.19'} + + '@tanstack/router-generator@1.167.21': + resolution: {integrity: sha512-m3oXZyienj8owialdyoZ0txHQrnEx/Ra+D9kWtar5fC2cWZr5Pvxl86VY2mX5RRLC5QLKLeRGT1x4HV95wHVDQ==} + engines: {node: '>=20.19'} + + '@tanstack/router-plugin@1.168.23': + resolution: {integrity: sha512-0+PIcvnaAimFwjoEIeV3h7LKjzC8zNnp7pH2UamdKwQ9QlY99WU9V0Xl0zbM0i9hrUa/mKgWPDAzELmPUu5fMA==} + engines: {node: '>=20.19'} + peerDependencies: + '@rsbuild/core': '>=1.0.2 || ^2.0.0' + '@tanstack/react-router': ^1.170.18 + vite: '>=5.0.0 || >=6.0.0 || >=7.0.0 || >=8.0.0' + vite-plugin-solid: ^2.11.10 || ^3.0.0-0 + webpack: '>=5.92.0' + peerDependenciesMeta: + '@rsbuild/core': + optional: true + '@tanstack/react-router': + optional: true + vite: + optional: true + vite-plugin-solid: + optional: true + webpack: + optional: true + + '@tanstack/router-utils@1.162.2': + resolution: {integrity: sha512-hTWqJtqIFFdvuCl8WXNyrodp2L9zo2G37xKRrcVmVRWpAB2h+U1LuRAfS4tsFTiWOIoE/B+WDVFB8JpoEdw6jQ==} + engines: {node: '>=20.19'} + + '@tanstack/store@0.9.3': + resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==} + + '@tanstack/virtual-file-routes@1.162.0': + resolution: {integrity: sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA==} + engines: {node: '>=20.19'} + '@testing-library/jest-dom@6.9.1': resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} engines: {node: '>=14', npm: '>=6', yarn: '>=1'} @@ -2413,6 +2564,10 @@ packages: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} + ansis@4.3.1: + resolution: {integrity: sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==} + engines: {node: '>=14'} + anymatch@3.1.3: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} @@ -2469,6 +2624,9 @@ packages: axios@1.15.0: resolution: {integrity: sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==} + babel-dead-code-elimination@1.0.12: + resolution: {integrity: sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==} + babel-jest@29.7.0: resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -2728,6 +2886,9 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-es@3.1.1: + resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==} + cookie@0.7.2: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} @@ -2983,6 +3144,10 @@ packages: resolution: {integrity: sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==} engines: {node: '>=0.3.1'} + diff@8.0.4: + resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} + engines: {node: '>=0.3.1'} + dom-accessibility-api@0.6.3: resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} @@ -3656,6 +3821,10 @@ packages: isarray@2.0.5: resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + isbot@5.2.1: + resolution: {integrity: sha512-dJ+LpKyClQZ7NG+j3OensC/mAZkGpukE9YUrgPYvAZj2doVL0edfDgywTUh5CXa0o+nW9a1V9e5+CJTX8+SxRw==} + engines: {node: '>=18'} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -3827,6 +3996,10 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} @@ -5117,6 +5290,16 @@ packages: serialize-javascript@6.0.2: resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} + seroval-plugins@1.5.6: + resolution: {integrity: sha512-HXuLAX2pu/UByPpaeo/TaMfvMIi+1QqIoPJYCcAtU8QkVNwgR6MPlGuCQTErV1JwraaMbYaWVIBX7mppzGLATQ==} + engines: {node: '>=10'} + peerDependencies: + seroval: ^1.0 + + seroval@1.5.6: + resolution: {integrity: sha512-rVQVWjjSvlINzaQPZH5JFqsqEsIWdTxY3iJZCnTL/5gQbXIRooVZKI60tVCkOVfzcRPejboxO2t0P89dg5mQaA==} + engines: {node: '>=10'} + set-cookie-parser@2.7.2: resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} @@ -5647,12 +5830,48 @@ packages: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} + unplugin@3.3.0: + resolution: {integrity: sha512-qa66K+crbfyE6JK10GjvbJeRrOsuC/JpbnHctfyp/i4oBTxWOzJfRZyDiOk1PtErMFRu8JhsU/wPvOdBNWe5Rg==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@farmfe/core': '*' + '@rspack/core': '*' + bun-types-no-globals: '*' + esbuild: '*' + rolldown: '*' + rollup: '*' + unloader: '*' + vite: '*' + webpack: '*' + peerDependenciesMeta: + '@farmfe/core': + optional: true + '@rspack/core': + optional: true + bun-types-no-globals: + optional: true + esbuild: + optional: true + rolldown: + optional: true + rollup: + optional: true + unloader: + optional: true + vite: + optional: true + webpack: + optional: true + update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true peerDependencies: browserslist: '>= 4.21.0' + url-search-params-polyfill@8.2.5: + resolution: {integrity: sha512-FOEojW4XReTmtZOB7xqSHmJZhrNTmClhBriwLTmle4iA7bwuCo6ldSfbtsFSb8bTf3E0a3XpfonAdaur9vqq8A==} + use-sync-external-store@1.6.0: resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} peerDependencies: @@ -5841,6 +6060,9 @@ packages: resolution: {integrity: sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q==} engines: {node: '>=10.13.0'} + webpack-virtual-modules@0.6.2: + resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + webpack@5.105.0: resolution: {integrity: sha512-gX/dMkRQc7QOMzgTe6KsYFM7DxeIONQSui1s0n/0xht36HvrgbxtM1xBlgx596NbpHuQU8P7QpKwrZYwUX48nw==} engines: {node: '>=10.13.0'} @@ -5975,6 +6197,9 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -6763,6 +6988,13 @@ snapshots: dependencies: '@lynx-js/webpack-runtime-globals': 0.0.6 + '@lynx-js/css-extract-webpack-plugin@0.7.0(@lynx-js/template-webpack-plugin@0.10.5(@lynx-js/lynx-core@0.1.3)(tslib@2.8.1))(webpack@5.105.0(esbuild@0.27.7))': + dependencies: + '@lynx-js/template-webpack-plugin': 0.10.5(@lynx-js/lynx-core@0.1.3)(tslib@2.8.1) + mini-css-extract-plugin: 2.10.2(webpack@5.105.0(esbuild@0.27.7)) + transitivePeerDependencies: + - webpack + '@lynx-js/css-extract-webpack-plugin@0.7.0(@lynx-js/template-webpack-plugin@0.10.5(@lynx-js/lynx-core@0.1.3)(tslib@2.8.1))(webpack@5.105.0)': dependencies: '@lynx-js/template-webpack-plugin': 0.10.5(@lynx-js/lynx-core@0.1.3)(tslib@2.8.1) @@ -6799,6 +7031,23 @@ snapshots: dependencies: '@lynx-js/react-webpack-plugin': 0.7.4(@lynx-js/react@0.116.5(@lynx-js/types@3.7.0)(@types/react@18.3.28))(@lynx-js/template-webpack-plugin@0.10.5(@lynx-js/lynx-core@0.1.3)(tslib@2.8.1)) + '@lynx-js/react-rsbuild-plugin@0.12.10(@lynx-js/lynx-core@0.1.3)(@lynx-js/react@0.116.5(@lynx-js/types@3.7.0)(@types/react@18.3.28))(tslib@2.8.1)(webpack@5.105.0(esbuild@0.27.7))': + dependencies: + '@lynx-js/css-extract-webpack-plugin': 0.7.0(@lynx-js/template-webpack-plugin@0.10.5(@lynx-js/lynx-core@0.1.3)(tslib@2.8.1))(webpack@5.105.0(esbuild@0.27.7)) + '@lynx-js/react-alias-rsbuild-plugin': 0.12.10 + '@lynx-js/react-refresh-webpack-plugin': 0.3.4(@lynx-js/react-webpack-plugin@0.7.4(@lynx-js/react@0.116.5(@lynx-js/types@3.7.0)(@types/react@18.3.28))(@lynx-js/template-webpack-plugin@0.10.5(@lynx-js/lynx-core@0.1.3)(tslib@2.8.1))) + '@lynx-js/react-webpack-plugin': 0.7.4(@lynx-js/react@0.116.5(@lynx-js/types@3.7.0)(@types/react@18.3.28))(@lynx-js/template-webpack-plugin@0.10.5(@lynx-js/lynx-core@0.1.3)(tslib@2.8.1)) + '@lynx-js/runtime-wrapper-webpack-plugin': 0.1.3 + '@lynx-js/template-webpack-plugin': 0.10.5(@lynx-js/lynx-core@0.1.3)(tslib@2.8.1) + '@lynx-js/use-sync-external-store': 1.5.0(@lynx-js/react@0.116.5(@lynx-js/types@3.7.0)(@types/react@18.3.28)) + background-only: 0.0.1 + optionalDependencies: + '@lynx-js/react': 0.116.5(@lynx-js/types@3.7.0)(@types/react@18.3.28) + transitivePeerDependencies: + - '@lynx-js/lynx-core' + - tslib + - webpack + '@lynx-js/react-rsbuild-plugin@0.12.10(@lynx-js/lynx-core@0.1.3)(@lynx-js/react@0.116.5(@lynx-js/types@3.7.0)(@types/react@18.3.28))(tslib@2.8.1)(webpack@5.105.0)': dependencies: '@lynx-js/css-extract-webpack-plugin': 0.7.0(@lynx-js/template-webpack-plugin@0.10.5(@lynx-js/lynx-core@0.1.3)(tslib@2.8.1))(webpack@5.105.0) @@ -6831,6 +7080,32 @@ snapshots: optionalDependencies: '@lynx-js/types': 3.7.0 + '@lynx-js/rspeedy@0.13.6(@rspack/core@1.7.11(@swc/helpers@0.5.21))(esbuild@0.27.7)(typescript@5.9.3)(webpack@5.105.0(esbuild@0.27.7))': + dependencies: + '@lynx-js/cache-events-webpack-plugin': 0.0.3 + '@lynx-js/chunk-loading-webpack-plugin': 0.3.3 + '@lynx-js/web-rsbuild-server-middleware': 0.19.9 + '@lynx-js/webpack-dev-transport': 0.2.0 + '@lynx-js/websocket': 0.0.4 + '@rsbuild/core': 1.7.3 + '@rsbuild/plugin-css-minimizer': 1.1.1(@rsbuild/core@1.7.3)(esbuild@0.27.7)(webpack@5.105.0(esbuild@0.27.7)) + '@rsdoctor/rspack-plugin': 1.2.3(@rsbuild/core@1.7.3)(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.105.0(esbuild@0.27.7)) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@parcel/css' + - '@rspack/core' + - '@swc/css' + - bufferutil + - clean-css + - csso + - debug + - esbuild + - lightningcss + - supports-color + - utf-8-validate + - webpack + '@lynx-js/rspeedy@0.13.6(@rspack/core@1.7.11(@swc/helpers@0.5.21))(typescript@5.9.3)(webpack@5.105.0)': dependencies: '@lynx-js/cache-events-webpack-plugin': 0.0.3 @@ -7191,6 +7466,21 @@ snapshots: optionalDependencies: '@rsbuild/core': 1.7.3 + '@rsbuild/plugin-css-minimizer@1.1.1(@rsbuild/core@1.7.3)(esbuild@0.27.7)(webpack@5.105.0(esbuild@0.27.7))': + dependencies: + css-minimizer-webpack-plugin: 7.0.2(esbuild@0.27.7)(webpack@5.105.0(esbuild@0.27.7)) + reduce-configs: 1.1.2 + optionalDependencies: + '@rsbuild/core': 1.7.3 + transitivePeerDependencies: + - '@parcel/css' + - '@swc/css' + - clean-css + - csso + - esbuild + - lightningcss + - webpack + '@rsbuild/plugin-css-minimizer@1.1.1(@rsbuild/core@1.7.3)(webpack@5.105.0)': dependencies: css-minimizer-webpack-plugin: 7.0.2(webpack@5.105.0) @@ -7227,6 +7517,31 @@ snapshots: '@rsdoctor/client@1.2.3': {} + '@rsdoctor/core@1.2.3(@rsbuild/core@1.7.3)(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.105.0(esbuild@0.27.7))': + dependencies: + '@rsbuild/plugin-check-syntax': 1.3.0(@rsbuild/core@1.7.3) + '@rsdoctor/graph': 1.2.3(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.105.0(esbuild@0.27.7)) + '@rsdoctor/sdk': 1.2.3(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.105.0(esbuild@0.27.7)) + '@rsdoctor/types': 1.2.3(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.105.0(esbuild@0.27.7)) + '@rsdoctor/utils': 1.2.3(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.105.0(esbuild@0.27.7)) + axios: 1.15.0 + browserslist-load-config: 1.0.1 + enhanced-resolve: 5.12.0 + filesize: 10.1.6 + fs-extra: 11.3.4 + lodash: 4.18.1 + path-browserify: 1.0.1 + semver: 7.7.4 + source-map: 0.7.6 + transitivePeerDependencies: + - '@rsbuild/core' + - '@rspack/core' + - bufferutil + - debug + - supports-color + - utf-8-validate + - webpack + '@rsdoctor/core@1.2.3(@rsbuild/core@1.7.3)(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.105.0)': dependencies: '@rsbuild/plugin-check-syntax': 1.3.0(@rsbuild/core@1.7.3) @@ -7252,6 +7567,17 @@ snapshots: - utf-8-validate - webpack + '@rsdoctor/graph@1.2.3(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.105.0(esbuild@0.27.7))': + dependencies: + '@rsdoctor/types': 1.2.3(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.105.0(esbuild@0.27.7)) + '@rsdoctor/utils': 1.2.3(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.105.0(esbuild@0.27.7)) + lodash.unionby: 4.8.0 + source-map: 0.7.6 + transitivePeerDependencies: + - '@rspack/core' + - supports-color + - webpack + '@rsdoctor/graph@1.2.3(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.105.0)': dependencies: '@rsdoctor/types': 1.2.3(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.105.0) @@ -7263,6 +7589,24 @@ snapshots: - supports-color - webpack + '@rsdoctor/rspack-plugin@1.2.3(@rsbuild/core@1.7.3)(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.105.0(esbuild@0.27.7))': + dependencies: + '@rsdoctor/core': 1.2.3(@rsbuild/core@1.7.3)(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.105.0(esbuild@0.27.7)) + '@rsdoctor/graph': 1.2.3(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.105.0(esbuild@0.27.7)) + '@rsdoctor/sdk': 1.2.3(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.105.0(esbuild@0.27.7)) + '@rsdoctor/types': 1.2.3(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.105.0(esbuild@0.27.7)) + '@rsdoctor/utils': 1.2.3(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.105.0(esbuild@0.27.7)) + lodash: 4.18.1 + optionalDependencies: + '@rspack/core': 1.7.11(@swc/helpers@0.5.21) + transitivePeerDependencies: + - '@rsbuild/core' + - bufferutil + - debug + - supports-color + - utf-8-validate + - webpack + '@rsdoctor/rspack-plugin@1.2.3(@rsbuild/core@1.7.3)(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.105.0)': dependencies: '@rsdoctor/core': 1.2.3(@rsbuild/core@1.7.3)(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.105.0) @@ -7281,6 +7625,30 @@ snapshots: - utf-8-validate - webpack + '@rsdoctor/sdk@1.2.3(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.105.0(esbuild@0.27.7))': + dependencies: + '@rsdoctor/client': 1.2.3 + '@rsdoctor/graph': 1.2.3(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.105.0(esbuild@0.27.7)) + '@rsdoctor/types': 1.2.3(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.105.0(esbuild@0.27.7)) + '@rsdoctor/utils': 1.2.3(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.105.0(esbuild@0.27.7)) + '@types/fs-extra': 11.0.4 + body-parser: 1.20.3 + cors: 2.8.5 + dayjs: 1.11.13 + fs-extra: 11.3.4 + json-cycle: 1.5.0 + open: 8.4.2 + sirv: 2.0.4 + socket.io: 4.8.1 + source-map: 0.7.6 + tapable: 2.2.2 + transitivePeerDependencies: + - '@rspack/core' + - bufferutil + - supports-color + - utf-8-validate + - webpack + '@rsdoctor/sdk@1.2.3(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.105.0)': dependencies: '@rsdoctor/client': 1.2.3 @@ -7305,6 +7673,16 @@ snapshots: - utf-8-validate - webpack + '@rsdoctor/types@1.2.3(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.105.0(esbuild@0.27.7))': + dependencies: + '@types/connect': 3.4.38 + '@types/estree': 1.0.5 + '@types/tapable': 2.2.7 + source-map: 0.7.6 + optionalDependencies: + '@rspack/core': 1.7.11(@swc/helpers@0.5.21) + webpack: 5.105.0(esbuild@0.27.7) + '@rsdoctor/types@1.2.3(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.105.0)': dependencies: '@types/connect': 3.4.38 @@ -7315,6 +7693,30 @@ snapshots: '@rspack/core': 1.7.11(@swc/helpers@0.5.21) webpack: 5.105.0 + '@rsdoctor/utils@1.2.3(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.105.0(esbuild@0.27.7))': + dependencies: + '@babel/code-frame': 7.26.2 + '@rsdoctor/types': 1.2.3(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.105.0(esbuild@0.27.7)) + '@types/estree': 1.0.5 + acorn: 8.16.0 + acorn-import-attributes: 1.9.5(acorn@8.16.0) + acorn-walk: 8.3.4 + connect: 3.7.0 + deep-eql: 4.1.4 + envinfo: 7.14.0 + filesize: 10.1.6 + fs-extra: 11.3.4 + get-port: 5.1.1 + json-stream-stringify: 3.0.1 + lines-and-columns: 2.0.4 + picocolors: 1.1.1 + rslog: 1.3.2 + strip-ansi: 6.0.1 + transitivePeerDependencies: + - '@rspack/core' + - supports-color + - webpack + '@rsdoctor/utils@1.2.3(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.105.0)': dependencies: '@babel/code-frame': 7.26.2 @@ -7702,6 +8104,89 @@ snapshots: dependencies: tslib: 2.8.1 + '@tanstack/history@1.161.6': {} + + '@tanstack/history@1.162.0': {} + + '@tanstack/react-router@1.170.18(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@tanstack/history': 1.162.0 + '@tanstack/react-store': 0.9.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@tanstack/router-core': 1.171.15 + isbot: 5.2.1 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + + '@tanstack/react-store@0.9.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@tanstack/store': 0.9.3 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + use-sync-external-store: 1.6.0(react@19.2.5) + + '@tanstack/router-core@1.171.15': + dependencies: + '@tanstack/history': 1.162.0 + cookie-es: 3.1.1 + seroval: 1.5.6 + seroval-plugins: 1.5.6(seroval@1.5.6) + + '@tanstack/router-generator@1.167.21': + dependencies: + '@babel/types': 7.29.0 + '@tanstack/router-core': 1.171.15 + '@tanstack/router-utils': 1.162.2 + '@tanstack/virtual-file-routes': 1.162.0 + jiti: 2.7.0 + magic-string: 0.30.21 + prettier: 3.8.3 + zod: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@tanstack/router-plugin@1.168.23(@rsbuild/core@1.7.2)(@rspack/core@1.7.11(@swc/helpers@0.5.21))(@tanstack/react-router@1.170.18(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(esbuild@0.27.7)(rollup@4.60.1)(vite@7.3.2(@types/node@25.6.0)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3))(webpack@5.105.0(esbuild@0.27.7))': + dependencies: + '@babel/core': 7.29.0 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + '@tanstack/router-core': 1.171.15 + '@tanstack/router-generator': 1.167.21 + '@tanstack/router-utils': 1.162.2 + chokidar: 5.0.0 + unplugin: 3.3.0(@rspack/core@1.7.11(@swc/helpers@0.5.21))(esbuild@0.27.7)(rollup@4.60.1)(vite@7.3.2(@types/node@25.6.0)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3))(webpack@5.105.0(esbuild@0.27.7)) + zod: 4.4.3 + optionalDependencies: + '@rsbuild/core': 1.7.2 + '@tanstack/react-router': 1.170.18(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + vite: 7.3.2(@types/node@25.6.0)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3) + webpack: 5.105.0(esbuild@0.27.7) + transitivePeerDependencies: + - '@farmfe/core' + - '@rspack/core' + - bun-types-no-globals + - esbuild + - rolldown + - rollup + - supports-color + - unloader + + '@tanstack/router-utils@1.162.2': + dependencies: + '@babel/generator': 7.29.1 + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + ansis: 4.3.1 + babel-dead-code-elimination: 1.0.12 + diff: 8.0.4 + pathe: 2.0.3 + tinyglobby: 0.2.16 + transitivePeerDependencies: + - supports-color + + '@tanstack/store@0.9.3': {} + + '@tanstack/virtual-file-routes@1.162.0': {} + '@testing-library/jest-dom@6.9.1': dependencies: '@adobe/css-tools': 4.4.4 @@ -8085,7 +8570,7 @@ snapshots: react: 19.2.5 unhead: 2.1.13 - '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.6.0)(jiti@2.6.1)(jsdom@26.1.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3))': + '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.6.0)(jiti@2.7.0)(jsdom@26.1.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 @@ -8100,7 +8585,7 @@ snapshots: std-env: 3.10.0 test-exclude: 7.0.2 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.13)(@types/node@25.6.0)(jiti@2.6.1)(jsdom@26.1.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3) + vitest: 3.2.4(@types/debug@4.1.13)(@types/node@25.6.0)(jiti@2.7.0)(jsdom@26.1.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3) transitivePeerDependencies: - supports-color @@ -8116,7 +8601,7 @@ snapshots: obug: 2.1.1 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.4(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4)(jsdom@28.1.0)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3)) + vitest: 4.1.4(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4)(jsdom@28.1.0)(vite@7.3.2(@types/node@25.6.0)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3)) '@vitest/expect@3.2.4': dependencies: @@ -8135,21 +8620,21 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@3.2.4(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3))': + '@vitest/mocker@3.2.4(vite@7.3.2(@types/node@25.6.0)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3) + vite: 7.3.2(@types/node@25.6.0)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3) - '@vitest/mocker@4.1.4(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3))': + '@vitest/mocker@4.1.4(vite@7.3.2(@types/node@25.6.0)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3))': dependencies: '@vitest/spy': 4.1.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3) + vite: 7.3.2(@types/node@25.6.0)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3) '@vitest/pretty-format@3.2.4': dependencies: @@ -8342,6 +8827,8 @@ snapshots: ansi-styles@6.2.3: {} + ansis@4.3.1: {} + anymatch@3.1.3: dependencies: normalize-path: 3.0.0 @@ -8406,6 +8893,15 @@ snapshots: transitivePeerDependencies: - debug + babel-dead-code-elimination@1.0.12: + dependencies: + '@babel/core': 7.29.0 + '@babel/parser': 7.29.2 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + babel-jest@29.7.0(@babel/core@7.29.0): dependencies: '@babel/core': 7.29.0 @@ -8611,7 +9107,6 @@ snapshots: chokidar@5.0.0: dependencies: readdirp: 5.0.0 - optional: true chrome-trace-event@1.0.4: {} @@ -8674,6 +9169,8 @@ snapshots: convert-source-map@2.0.0: {} + cookie-es@3.1.1: {} + cookie@0.7.2: {} cookie@1.1.1: {} @@ -8729,16 +9226,28 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 - css-declaration-sorter@7.4.0(postcss@8.5.10): + css-declaration-sorter@7.4.0(postcss@8.5.17): dependencies: - postcss: 8.5.10 + postcss: 8.5.17 + + css-minimizer-webpack-plugin@7.0.2(esbuild@0.27.7)(webpack@5.105.0(esbuild@0.27.7)): + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + cssnano: 7.1.5(postcss@8.5.17) + jest-worker: 29.7.0 + postcss: 8.5.17 + schema-utils: 4.3.3 + serialize-javascript: 6.0.2 + webpack: 5.105.0(esbuild@0.27.7) + optionalDependencies: + esbuild: 0.27.7 css-minimizer-webpack-plugin@7.0.2(webpack@5.105.0): dependencies: '@jridgewell/trace-mapping': 0.3.31 - cssnano: 7.1.5(postcss@8.5.10) + cssnano: 7.1.5(postcss@8.5.17) jest-worker: 29.7.0 - postcss: 8.5.10 + postcss: 8.5.17 schema-utils: 4.3.3 serialize-javascript: 6.0.2 webpack: 5.105.0 @@ -8767,49 +9276,49 @@ snapshots: cssesc@3.0.0: {} - cssnano-preset-default@7.0.13(postcss@8.5.10): + cssnano-preset-default@7.0.13(postcss@8.5.17): dependencies: browserslist: 4.28.2 - css-declaration-sorter: 7.4.0(postcss@8.5.10) - cssnano-utils: 5.0.1(postcss@8.5.10) - postcss: 8.5.10 - postcss-calc: 10.1.1(postcss@8.5.10) - postcss-colormin: 7.0.8(postcss@8.5.10) - postcss-convert-values: 7.0.10(postcss@8.5.10) - postcss-discard-comments: 7.0.6(postcss@8.5.10) - postcss-discard-duplicates: 7.0.2(postcss@8.5.10) - postcss-discard-empty: 7.0.1(postcss@8.5.10) - postcss-discard-overridden: 7.0.1(postcss@8.5.10) - postcss-merge-longhand: 7.0.5(postcss@8.5.10) - postcss-merge-rules: 7.0.9(postcss@8.5.10) - postcss-minify-font-values: 7.0.1(postcss@8.5.10) - postcss-minify-gradients: 7.0.3(postcss@8.5.10) - postcss-minify-params: 7.0.7(postcss@8.5.10) - postcss-minify-selectors: 7.0.6(postcss@8.5.10) - postcss-normalize-charset: 7.0.1(postcss@8.5.10) - postcss-normalize-display-values: 7.0.1(postcss@8.5.10) - postcss-normalize-positions: 7.0.1(postcss@8.5.10) - postcss-normalize-repeat-style: 7.0.1(postcss@8.5.10) - postcss-normalize-string: 7.0.1(postcss@8.5.10) - postcss-normalize-timing-functions: 7.0.1(postcss@8.5.10) - postcss-normalize-unicode: 7.0.7(postcss@8.5.10) - postcss-normalize-url: 7.0.1(postcss@8.5.10) - postcss-normalize-whitespace: 7.0.1(postcss@8.5.10) - postcss-ordered-values: 7.0.2(postcss@8.5.10) - postcss-reduce-initial: 7.0.7(postcss@8.5.10) - postcss-reduce-transforms: 7.0.1(postcss@8.5.10) - postcss-svgo: 7.1.1(postcss@8.5.10) - postcss-unique-selectors: 7.0.5(postcss@8.5.10) - - cssnano-utils@5.0.1(postcss@8.5.10): + css-declaration-sorter: 7.4.0(postcss@8.5.17) + cssnano-utils: 5.0.1(postcss@8.5.17) + postcss: 8.5.17 + postcss-calc: 10.1.1(postcss@8.5.17) + postcss-colormin: 7.0.8(postcss@8.5.17) + postcss-convert-values: 7.0.10(postcss@8.5.17) + postcss-discard-comments: 7.0.6(postcss@8.5.17) + postcss-discard-duplicates: 7.0.2(postcss@8.5.17) + postcss-discard-empty: 7.0.1(postcss@8.5.17) + postcss-discard-overridden: 7.0.1(postcss@8.5.17) + postcss-merge-longhand: 7.0.5(postcss@8.5.17) + postcss-merge-rules: 7.0.9(postcss@8.5.17) + postcss-minify-font-values: 7.0.1(postcss@8.5.17) + postcss-minify-gradients: 7.0.3(postcss@8.5.17) + postcss-minify-params: 7.0.7(postcss@8.5.17) + postcss-minify-selectors: 7.0.6(postcss@8.5.17) + postcss-normalize-charset: 7.0.1(postcss@8.5.17) + postcss-normalize-display-values: 7.0.1(postcss@8.5.17) + postcss-normalize-positions: 7.0.1(postcss@8.5.17) + postcss-normalize-repeat-style: 7.0.1(postcss@8.5.17) + postcss-normalize-string: 7.0.1(postcss@8.5.17) + postcss-normalize-timing-functions: 7.0.1(postcss@8.5.17) + postcss-normalize-unicode: 7.0.7(postcss@8.5.17) + postcss-normalize-url: 7.0.1(postcss@8.5.17) + postcss-normalize-whitespace: 7.0.1(postcss@8.5.17) + postcss-ordered-values: 7.0.2(postcss@8.5.17) + postcss-reduce-initial: 7.0.7(postcss@8.5.17) + postcss-reduce-transforms: 7.0.1(postcss@8.5.17) + postcss-svgo: 7.1.1(postcss@8.5.17) + postcss-unique-selectors: 7.0.5(postcss@8.5.17) + + cssnano-utils@5.0.1(postcss@8.5.17): dependencies: - postcss: 8.5.10 + postcss: 8.5.17 - cssnano@7.1.5(postcss@8.5.10): + cssnano@7.1.5(postcss@8.5.17): dependencies: - cssnano-preset-default: 7.0.13(postcss@8.5.10) + cssnano-preset-default: 7.0.13(postcss@8.5.17) lilconfig: 3.1.3 - postcss: 8.5.10 + postcss: 8.5.17 csso@5.0.5: dependencies: @@ -8934,6 +9443,8 @@ snapshots: diff@4.0.4: {} + diff@8.0.4: {} + dom-accessibility-api@0.6.3: {} dom-serializer@2.0.0: @@ -9781,6 +10292,8 @@ snapshots: isarray@2.0.5: {} + isbot@5.2.1: {} + isexe@2.0.0: {} istanbul-lib-coverage@3.2.2: {} @@ -10248,6 +10761,8 @@ snapshots: jiti@2.6.1: {} + jiti@2.7.0: {} + js-tokens@10.0.0: {} js-tokens@4.0.0: {} @@ -10920,6 +11435,12 @@ snapshots: min-indent@1.0.1: {} + mini-css-extract-plugin@2.10.2(webpack@5.105.0(esbuild@0.27.7)): + dependencies: + schema-utils: 4.3.3 + tapable: 2.3.2 + webpack: 5.105.0(esbuild@0.27.7) + mini-css-extract-plugin@2.10.2(webpack@5.105.0): dependencies: schema-utils: 4.3.3 @@ -11132,142 +11653,142 @@ snapshots: possible-typed-array-names@1.1.0: {} - postcss-calc@10.1.1(postcss@8.5.10): + postcss-calc@10.1.1(postcss@8.5.17): dependencies: - postcss: 8.5.10 + postcss: 8.5.17 postcss-selector-parser: 7.1.1 postcss-value-parser: 4.2.0 - postcss-colormin@7.0.8(postcss@8.5.10): + postcss-colormin@7.0.8(postcss@8.5.17): dependencies: '@colordx/core': 5.0.3 browserslist: 4.28.2 caniuse-api: 3.0.0 - postcss: 8.5.10 + postcss: 8.5.17 postcss-value-parser: 4.2.0 - postcss-convert-values@7.0.10(postcss@8.5.10): + postcss-convert-values@7.0.10(postcss@8.5.17): dependencies: browserslist: 4.28.2 - postcss: 8.5.10 + postcss: 8.5.17 postcss-value-parser: 4.2.0 - postcss-discard-comments@7.0.6(postcss@8.5.10): + postcss-discard-comments@7.0.6(postcss@8.5.17): dependencies: - postcss: 8.5.10 + postcss: 8.5.17 postcss-selector-parser: 7.1.1 - postcss-discard-duplicates@7.0.2(postcss@8.5.10): + postcss-discard-duplicates@7.0.2(postcss@8.5.17): dependencies: - postcss: 8.5.10 + postcss: 8.5.17 - postcss-discard-empty@7.0.1(postcss@8.5.10): + postcss-discard-empty@7.0.1(postcss@8.5.17): dependencies: - postcss: 8.5.10 + postcss: 8.5.17 - postcss-discard-overridden@7.0.1(postcss@8.5.10): + postcss-discard-overridden@7.0.1(postcss@8.5.17): dependencies: - postcss: 8.5.10 + postcss: 8.5.17 - postcss-merge-longhand@7.0.5(postcss@8.5.10): + postcss-merge-longhand@7.0.5(postcss@8.5.17): dependencies: - postcss: 8.5.10 + postcss: 8.5.17 postcss-value-parser: 4.2.0 - stylehacks: 7.0.9(postcss@8.5.10) + stylehacks: 7.0.9(postcss@8.5.17) - postcss-merge-rules@7.0.9(postcss@8.5.10): + postcss-merge-rules@7.0.9(postcss@8.5.17): dependencies: browserslist: 4.28.2 caniuse-api: 3.0.0 - cssnano-utils: 5.0.1(postcss@8.5.10) - postcss: 8.5.10 + cssnano-utils: 5.0.1(postcss@8.5.17) + postcss: 8.5.17 postcss-selector-parser: 7.1.1 - postcss-minify-font-values@7.0.1(postcss@8.5.10): + postcss-minify-font-values@7.0.1(postcss@8.5.17): dependencies: - postcss: 8.5.10 + postcss: 8.5.17 postcss-value-parser: 4.2.0 - postcss-minify-gradients@7.0.3(postcss@8.5.10): + postcss-minify-gradients@7.0.3(postcss@8.5.17): dependencies: '@colordx/core': 5.0.3 - cssnano-utils: 5.0.1(postcss@8.5.10) - postcss: 8.5.10 + cssnano-utils: 5.0.1(postcss@8.5.17) + postcss: 8.5.17 postcss-value-parser: 4.2.0 - postcss-minify-params@7.0.7(postcss@8.5.10): + postcss-minify-params@7.0.7(postcss@8.5.17): dependencies: browserslist: 4.28.2 - cssnano-utils: 5.0.1(postcss@8.5.10) - postcss: 8.5.10 + cssnano-utils: 5.0.1(postcss@8.5.17) + postcss: 8.5.17 postcss-value-parser: 4.2.0 - postcss-minify-selectors@7.0.6(postcss@8.5.10): + postcss-minify-selectors@7.0.6(postcss@8.5.17): dependencies: cssesc: 3.0.0 - postcss: 8.5.10 + postcss: 8.5.17 postcss-selector-parser: 7.1.1 - postcss-normalize-charset@7.0.1(postcss@8.5.10): + postcss-normalize-charset@7.0.1(postcss@8.5.17): dependencies: - postcss: 8.5.10 + postcss: 8.5.17 - postcss-normalize-display-values@7.0.1(postcss@8.5.10): + postcss-normalize-display-values@7.0.1(postcss@8.5.17): dependencies: - postcss: 8.5.10 + postcss: 8.5.17 postcss-value-parser: 4.2.0 - postcss-normalize-positions@7.0.1(postcss@8.5.10): + postcss-normalize-positions@7.0.1(postcss@8.5.17): dependencies: - postcss: 8.5.10 + postcss: 8.5.17 postcss-value-parser: 4.2.0 - postcss-normalize-repeat-style@7.0.1(postcss@8.5.10): + postcss-normalize-repeat-style@7.0.1(postcss@8.5.17): dependencies: - postcss: 8.5.10 + postcss: 8.5.17 postcss-value-parser: 4.2.0 - postcss-normalize-string@7.0.1(postcss@8.5.10): + postcss-normalize-string@7.0.1(postcss@8.5.17): dependencies: - postcss: 8.5.10 + postcss: 8.5.17 postcss-value-parser: 4.2.0 - postcss-normalize-timing-functions@7.0.1(postcss@8.5.10): + postcss-normalize-timing-functions@7.0.1(postcss@8.5.17): dependencies: - postcss: 8.5.10 + postcss: 8.5.17 postcss-value-parser: 4.2.0 - postcss-normalize-unicode@7.0.7(postcss@8.5.10): + postcss-normalize-unicode@7.0.7(postcss@8.5.17): dependencies: browserslist: 4.28.2 - postcss: 8.5.10 + postcss: 8.5.17 postcss-value-parser: 4.2.0 - postcss-normalize-url@7.0.1(postcss@8.5.10): + postcss-normalize-url@7.0.1(postcss@8.5.17): dependencies: - postcss: 8.5.10 + postcss: 8.5.17 postcss-value-parser: 4.2.0 - postcss-normalize-whitespace@7.0.1(postcss@8.5.10): + postcss-normalize-whitespace@7.0.1(postcss@8.5.17): dependencies: - postcss: 8.5.10 + postcss: 8.5.17 postcss-value-parser: 4.2.0 - postcss-ordered-values@7.0.2(postcss@8.5.10): + postcss-ordered-values@7.0.2(postcss@8.5.17): dependencies: - cssnano-utils: 5.0.1(postcss@8.5.10) - postcss: 8.5.10 + cssnano-utils: 5.0.1(postcss@8.5.17) + postcss: 8.5.17 postcss-value-parser: 4.2.0 - postcss-reduce-initial@7.0.7(postcss@8.5.10): + postcss-reduce-initial@7.0.7(postcss@8.5.17): dependencies: browserslist: 4.28.2 caniuse-api: 3.0.0 - postcss: 8.5.10 + postcss: 8.5.17 - postcss-reduce-transforms@7.0.1(postcss@8.5.10): + postcss-reduce-transforms@7.0.1(postcss@8.5.17): dependencies: - postcss: 8.5.10 + postcss: 8.5.17 postcss-value-parser: 4.2.0 postcss-selector-parser@7.1.1: @@ -11275,15 +11796,15 @@ snapshots: cssesc: 3.0.0 util-deprecate: 1.0.2 - postcss-svgo@7.1.1(postcss@8.5.10): + postcss-svgo@7.1.1(postcss@8.5.17): dependencies: - postcss: 8.5.10 + postcss: 8.5.17 postcss-value-parser: 4.2.0 svgo: 4.0.1 - postcss-unique-selectors@7.0.5(postcss@8.5.10): + postcss-unique-selectors@7.0.5(postcss@8.5.17): dependencies: - postcss: 8.5.10 + postcss: 8.5.17 postcss-selector-parser: 7.1.1 postcss-value-parser@4.2.0: {} @@ -11492,8 +12013,7 @@ snapshots: react@19.2.5: {} - readdirp@5.0.0: - optional: true + readdirp@5.0.0: {} recma-build-jsx@1.0.0: dependencies: @@ -11870,6 +12390,12 @@ snapshots: dependencies: randombytes: 2.1.0 + seroval-plugins@1.5.6(seroval@1.5.6): + dependencies: + seroval: 1.5.6 + + seroval@1.5.6: {} + set-cookie-parser@2.7.2: {} set-function-length@1.2.2: @@ -12151,10 +12677,10 @@ snapshots: dependencies: inline-style-parser: 0.2.7 - stylehacks@7.0.9(postcss@8.5.10): + stylehacks@7.0.9(postcss@8.5.17): dependencies: browserslist: 4.28.2 - postcss: 8.5.10 + postcss: 8.5.17 postcss-selector-parser: 7.1.1 supports-color@7.2.0: @@ -12195,6 +12721,16 @@ snapshots: tapable@2.3.2: {} + terser-webpack-plugin@5.4.0(esbuild@0.27.7)(webpack@5.105.0(esbuild@0.27.7)): + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + jest-worker: 27.5.1 + schema-utils: 4.3.3 + terser: 5.46.1 + webpack: 5.105.0(esbuild@0.27.7) + optionalDependencies: + esbuild: 0.27.7 + terser-webpack-plugin@5.4.0(webpack@5.105.0): dependencies: '@jridgewell/trace-mapping': 0.3.31 @@ -12499,12 +13035,26 @@ snapshots: unpipe@1.0.0: {} + unplugin@3.3.0(@rspack/core@1.7.11(@swc/helpers@0.5.21))(esbuild@0.27.7)(rollup@4.60.1)(vite@7.3.2(@types/node@25.6.0)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3))(webpack@5.105.0(esbuild@0.27.7)): + dependencies: + '@jridgewell/remapping': 2.3.5 + picomatch: 4.0.4 + webpack-virtual-modules: 0.6.2 + optionalDependencies: + '@rspack/core': 1.7.11(@swc/helpers@0.5.21) + esbuild: 0.27.7 + rollup: 4.60.1 + vite: 7.3.2(@types/node@25.6.0)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3) + webpack: 5.105.0(esbuild@0.27.7) + update-browserslist-db@1.2.3(browserslist@4.28.2): dependencies: browserslist: 4.28.2 escalade: 3.2.0 picocolors: 1.1.1 + url-search-params-polyfill@8.2.5: {} + use-sync-external-store@1.6.0(react@19.2.5): dependencies: react: 19.2.5 @@ -12542,13 +13092,13 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite-node@3.2.4(@types/node@25.6.0)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3): + vite-node@3.2.4(@types/node@25.6.0)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3) + vite: 7.3.2(@types/node@25.6.0)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3) transitivePeerDependencies: - '@types/node' - jiti @@ -12563,7 +13113,7 @@ snapshots: - tsx - yaml - vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3): + vite@7.3.2(@types/node@25.6.0)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3): dependencies: esbuild: 0.27.7 fdir: 6.5.0(picomatch@4.0.4) @@ -12574,17 +13124,17 @@ snapshots: optionalDependencies: '@types/node': 25.6.0 fsevents: 2.3.3 - jiti: 2.6.1 + jiti: 2.7.0 sass: 1.100.0 sass-embedded: 1.100.0 terser: 5.46.1 yaml: 2.8.3 - vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.6.0)(jiti@2.6.1)(jsdom@26.1.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3): + vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.6.0)(jiti@2.7.0)(jsdom@26.1.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3)) + '@vitest/mocker': 3.2.4(vite@7.3.2(@types/node@25.6.0)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -12602,8 +13152,8 @@ snapshots: tinyglobby: 0.2.16 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3) - vite-node: 3.2.4(@types/node@25.6.0)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3) + vite: 7.3.2(@types/node@25.6.0)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3) + vite-node: 3.2.4(@types/node@25.6.0)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3) why-is-node-running: 2.3.0 optionalDependencies: '@types/debug': 4.1.13 @@ -12623,10 +13173,10 @@ snapshots: - tsx - yaml - vitest@4.1.4(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4)(jsdom@28.1.0)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3)): + vitest@4.1.4(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4)(jsdom@28.1.0)(vite@7.3.2(@types/node@25.6.0)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3)): dependencies: '@vitest/expect': 4.1.4 - '@vitest/mocker': 4.1.4(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3)) + '@vitest/mocker': 4.1.4(vite@7.3.2(@types/node@25.6.0)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3)) '@vitest/pretty-format': 4.1.4 '@vitest/runner': 4.1.4 '@vitest/snapshot': 4.1.4 @@ -12643,7 +13193,7 @@ snapshots: tinyexec: 1.1.1 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3) + vite: 7.3.2(@types/node@25.6.0)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.6.0 @@ -12681,6 +13231,8 @@ snapshots: webpack-sources@3.3.4: {} + webpack-virtual-modules@0.6.2: {} + webpack@5.105.0: dependencies: '@types/eslint-scope': 3.7.7 @@ -12713,6 +13265,38 @@ snapshots: - esbuild - uglify-js + webpack@5.105.0(esbuild@0.27.7): + dependencies: + '@types/eslint-scope': 3.7.7 + '@types/estree': 1.0.8 + '@types/json-schema': 7.0.15 + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/wasm-edit': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + acorn: 8.16.0 + acorn-import-phases: 1.0.4(acorn@8.16.0) + browserslist: 4.28.2 + chrome-trace-event: 1.0.4 + enhanced-resolve: 5.20.1 + es-module-lexer: 2.0.0 + eslint-scope: 5.1.1 + events: 3.3.0 + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.11 + json-parse-even-better-errors: 2.3.1 + loader-runner: 4.3.1 + mime-types: 2.1.35 + neo-async: 2.6.2 + schema-utils: 4.3.3 + tapable: 2.3.2 + terser-webpack-plugin: 5.4.0(esbuild@0.27.7)(webpack@5.105.0(esbuild@0.27.7)) + watchpack: 2.5.1 + webpack-sources: 3.3.4 + transitivePeerDependencies: + - '@swc/core' + - esbuild + - uglify-js + whatwg-encoding@3.1.1: dependencies: iconv-lite: 0.6.3 @@ -12835,4 +13419,6 @@ snapshots: yocto-queue@0.1.0: {} + zod@4.4.3: {} + zwitch@2.0.4: {}