Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions examples/router-demo/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
src/generated/
src/rspeedy-entries.gen.json
dist/
32 changes: 32 additions & 0 deletions examples/router-demo/README.md
Original file line number Diff line number Diff line change
@@ -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
```
56 changes: 56 additions & 0 deletions examples/router-demo/lynx.config.ts
Original file line number Diff line number Diff line change
@@ -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()],
})
41 changes: 41 additions & 0 deletions examples/router-demo/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
12 changes: 12 additions & 0 deletions examples/router-demo/scripts/codegen.mjs
Original file line number Diff line number Diff line change
@@ -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)}`,
)
24 changes: 24 additions & 0 deletions examples/router-demo/scripts/codegen.test.mjs
Original file line number Diff line number Diff line change
@@ -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))
})
52 changes: 52 additions & 0 deletions examples/router-demo/src/entries/main.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string> } }).__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(<RouterProvider router={router} />)
18 changes: 18 additions & 0 deletions examples/router-demo/src/react-lynx-shim.ts
Original file line number Diff line number Diff line change
@@ -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
38 changes: 38 additions & 0 deletions examples/router-demo/src/route-manifest.gen.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
]
}
113 changes: 113 additions & 0 deletions examples/router-demo/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
@@ -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<FileRouteTypes>()
Loading