Skip to content

TanStack Router driving native MPA navigation (sparkling-history + demo) - #1

Draft
Huxpro wants to merge 14 commits into
mainfrom
claude/tanstack-router-sparkling-vw2vfz
Draft

TanStack Router driving native MPA navigation (sparkling-history + demo)#1
Huxpro wants to merge 14 commits into
mainfrom
claude/tanstack-router-sparkling-vw2vfz

Conversation

@Huxpro

@Huxpro Huxpro commented Jul 12, 2026

Copy link
Copy Markdown
Owner

Summary

This PR proves and packages a "web-history shim" architecture that lets TanStack Router drive Sparkling's native multi-page navigation: each route subtree runs in its own container / JS context, in-page navigations stay SPA-style within one LynxView, and cross-page navigations become native router.open calls.

Note: an earlier revision of this description listed a web shell, web method implementations, and CLI --web support — those are not part of this PR. This description matches the actual diff.

What's in the diff

packages/sparkling-history (new)

A reusable, dependency-free shim with a three-layer architecture:

  • NavigationHost — the platform contract: report launch href/depth/state, open, close, plus an optional event face: subscribeStack broadcasting StackChangedEvents (push/pop/replace/container-back, with depth and pop result).
  • createMpaHistory() — a RouterHistory-shaped history (drop-in for createRouter({ history })): in-page destinations mutate a local memory stack; cross-page destinations forward to the host; back() below the local root becomes a native pop; closePage({ result }) closes the container handing a result to the page below.
  • createSparklingHost() — the sparkling binding: builds hybrid:// schemes without URL/URLSearchParams (the native Lynx runtime has neither), transports href/depth/state via query params, reconstructs the destination's initial location from queryItems, and falls back to the page's defaultHref (not /) for external deep links. Command-only for now — the native SDK doesn't broadcast stack events yet, so subscribeStack is deliberately absent for feature detection.
  • createMemoryHost() — test double that implements the full protocol including stack events and results; it is the executable spec (tests/stack-events.test.ts).
  • createStackMirror() — read-only, useSyncExternalStore-compatible mirror of the native stack with live feature detection.
  • createManifestPageResolver() / defaultHrefForPage() — longest-prefix route→page resolution from a build-time manifest.

packages/tanstack-router-demo (new)

  • Spike: TanStack Router on ReactLynx over memory history (single page) — proves runtime/component-layer compatibility with pinned @tanstack/react-router + shims (react$ composing @lynx-js/react/compat, react-dom$ flushSync, uSES → @lynx-js/use-sync-external-store, url-search-params-polyfill + env globals).
  • MPA: file-based routes via the official @tanstack/router-plugin/rspack generator, extended by scripts/gen-mpa.mjs (our page-boundary codegen: export const page = {...} in a route file → per-page rspeedy entries + serializable route→page manifest). Generator fails loudly on unparseable page exports.
  • Headless test suites: feature-support matrix, MPA boundary behavior, sparkling host, in-page parity, generated-tree, stack events.

Docs

docs/en/guide/tanstack-router.md: design rationale (MPA vs SPA), layered contracts, feature support matrix, ReactLynx shim inventory, comparison with TanStack's React Native adapter, and honestly-labeled limitations (full route tree in every bundle is a demo simplification; animated has no native support yet; blockers can't intercept container-initiated back; results/stack events await a native event face).

Known limitations / next steps

  • Not yet validated on a real device — Node tests and the go-web preview both provide URL/URLSearchParams natively, which is exactly why the missing-polyfill class of bugs doesn't show there. On-device validation of the MPA path is the acceptance item before undraft.
  • Native SDK gaps this PR designs around (tracked for the stack-protocol work): no stack-changed events, no close payload, animated ignored, close(containerID) ignored.
  • Per-page route-tree pruning in the MPA codegen (bundle size currently grows linearly with page count).

🤖 Generated with Claude Code

https://claude.ai/code/session_016wEPNgmHbba2Bgkr1KcDYa

@vercel

vercel Bot commented Jul 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
sparkling Ready Ready Preview, Comment Jul 24, 2026 9:10pm

Request Review

claude added 10 commits July 12, 2026 23:05
…igation

The core deliverable: a framework-agnostic layer that lets URL-driven
routers (TanStack Router, React Router, ...) drive native multi-page
navigation, where each route subtree runs in its own container / JS
context and pages are connected via pre-generated file-based metadata
rather than shared memory.

Three layers, cleanly separated:
- NavigationHost: the API contract a container platform implements
  (getInitialHref / getStackDepth / getInitialState / open / close).
  Framework- and platform-agnostic — future routers or hosts only need
  to satisfy this contract.
- createMpaHistory: a web-history shim implementing @tanstack/history's
  RouterHistory shape over a NavigationHost. In-page navigations behave
  exactly like a memory history; cross-page navigations (decided by a
  PageResolver) are forwarded to host.open as native page opens, and
  back at the local root becomes host.close. __TSR_index is seeded with
  native stack depth so back-affordances work across page boundaries.
  Unlike @tanstack/history, blockers run with no global document.
- createSparklingHost: the sparkling-navigation binding. Turns cross-page
  opens into hybrid:// schemes (bundle + container params + MPA transport
  params) and reconstructs a page's initial location from its launch
  queryItems. createManifestPageResolver builds the route->page mapping
  from a file-based manifest; createMemoryHost is a recording test double.

28 tests: in-page parity with @tanstack/history's memory history (ported
verbatim), MPA boundary behavior, and sparkling scheme round-tripping.
Runs in a plain node environment (no jsdom) to prove document-independence.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1EU55YA4e1zj2oVHh7Zme
Feasibility spike: @tanstack/react-router 1.170.17 renders and navigates
inside a Lynx view (verified via sparkling-web-shell + Playwright).
Required shims, all bundler-level:
- react$ alias adding startTransition/useTransition (from
  @lynx-js/react/compat) and a 'use' binding for strict ESM linking
- react-dom$ alias providing flushSync (the only react-dom symbol in
  TanStack Router's client entry)
- use-sync-external-store -> @lynx-js/use-sync-external-store
- global polyfills: scrollTo (router-core resets scroll even with
  scrollRestoration off), queueMicrotask, AbortController
- Lynx-native error/notFound components (TanStack defaults render DOM
  elements, which crash the background-thread renderer)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1EU55YA4e1zj2oVHh7Zme
…tion

A real multi-page TanStack Router app where each page is a separate
LynxView / JS context, wired to sparkling-navigation through
sparkling-history. Verified end-to-end on the web harness:

- Home and Profile share one bundle -> in-page (memory-history) transition
- Home -> Detail tiktok#42 -> cross-page: TanStack navigate() becomes a native
  router.open of the detail bundle; path param id=42 and search ref=home
  cross the JS-context boundary via the scheme query and are read back
  from queryItems
- Detail -> Back: router.history.back() at the page root becomes
  host.close() -> native pop, returning to Home

createMpaRouter() is the whole integration: createSparklingHost +
createMpaHistory + createRouter. Each page entry just calls mount(); the
router derives its start location from __mpa_href in the launch queryItems.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1EU55YA4e1zj2oVHh7Zme
…shim

12 integration tests driving a real @tanstack/react-router instance over
createMpaHistory in a plain node environment (no DOM), one per feature:
nested routes/outlet, path params, validated search, loaders, beforeLoad
+ context, redirect(), notFound(), error matches, imperative navigate,
route masking, DOM-free navigation blocking, and cross-page open
forwarding. These are the evidence behind the support matrix in the docs.

Also pin router origin to a constant in createMpaRouter: router-core reads
a bare window.origin when isServer is false, which is undeclared in a
native Lynx runtime (would throw ReferenceError).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1EU55YA4e1zj2oVHh7Zme
…omparison

Adds docs/en/guide/tanstack-router.md covering:
- the MPA-vs-SPA problem (separate JS contexts per page)
- the 3-layer architecture (NavigationHost contract → createMpaHistory
  → createSparklingHost) and the file-based route manifest
- a full feature support matrix: every routing-core feature (nested
  routes, params, search, loaders, beforeLoad, redirects, notFound,
  masking, blocking) marked supported with a backing test; the
  MPA-specific behaviors; the partial (preloading/transitions) and
  unsupported (scroll restoration, <Link>-as-anchor, SSR, devtools)
  features with reasons
- the four ReactLynx shims + two router options needed, and the two
  stacked web-support bugfixes
- a grounded comparison with TanStack's experimental React Native
  adapter: what's reusable, their single-heap advantages, our
  separate-VM advantages and limitations

Also links the guide in the website sidebar (en + zh), adds the
sparkling-history README, and drops the debug-only minify:false from the
demo build (bundles 1.5MB → 312KB).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1EU55YA4e1zj2oVHh7Zme
Converts the demo to TanStack Router's file-based convention and proves
the official compile-time toolchain composes with Rspeedy/Rspack/ReactLynx.

Official toolchain, reused as-is:
- src/routes/* file convention (createFileRoute); @tanstack/router-generator
  produces src/routeTree.gen.ts. Verified standalone AND running inside the
  rspeedy build via @tanstack/router-plugin/rspack (generator-only mode)
  composed alongside pluginReactLynx in tools.rspack — deleting
  routeTree.gen.ts and building regenerates it.

MPA extension TanStack does not provide (scripts/gen-mpa.mjs):
- reads the same route files' "export const page = { id, containerParams }"
  boundary markers and emits src/routes.manifest.ts (route->bundle mapping)
  and one generated bundle entry per native page (src/pages.gen/<id>), wired
  into rspeedy source.entry via src/page-entries.gen.ts.
- routes without a "page" export belong to the root page (index).

createMpaRouter now consumes the generated tree + generated manifest. Full
pipeline verified end-to-end on the web harness (Home -> native Detail tiktok#42
with params crossing the JS boundary) and by 16 headless tests, including
generated-tree.test.ts asserting the generated tree + manifest drive
navigation over the shim.

Replaces the hand-written route tree and page entries; shared UI extracted
to src/ui.tsx. codegen wired into build/dev/pretest scripts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1EU55YA4e1zj2oVHh7Zme
Documents that TanStack Router's compile-time toolchain is reusable in
Rspeedy/Rspack/ReactLynx:
- @tanstack/router-generator runs standalone AND via router-plugin/rspack
  composed with pluginReactLynx (generator-only, verified by delete+rebuild)
- autoCodeSplitting also composes; lazy chunks load/render on the web
  harness (kept off by default: per-page bundling already splits at page
  granularity, native async-chunk loading unverified)
- the MPA dimension TanStack does not provide (route->bundle manifest +
  per-page entries) is generated by scripts/gen-mpa.mjs from a "page"
  export marker on route files

Updates the support matrix (code-splitting row) and packages/files list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1EU55YA4e1zj2oVHh7Zme
Surfaces the tanstack-router-demo on the website using the go-web `<Go>`
component, alongside the existing Sparkling Go examples.

- packages/tanstack-router-demo/lynx.web.config.ts: a website-only build
  (mirrors the playground) emitting `*.web.bundle` + `*.lynx.bundle` into
  dist-web/ with the deployed examples assetPrefix. `build:web` now uses it;
  the dropped sparkling-web-shell dev script is removed.
- prepare-examples.mjs: register `tanstack-router` as a package source, and
  add explicit-`entries` support (this demo's entries are code-generated and
  invisible to the config regex). Exposes the `spike` and `home` entries.
- docs/en+zh/guide/examples/tanstack-router.mdx: a new Examples page with two
  live `<Go>` embeds (the single-page spike and the MPA home bundle),
  registered in the sidebar and linked from the Examples overview.

In-page routing runs live in the go-web web preview (verified end-to-end:
the spike's Home->/about transition renders in the browser). Cross-page
native navigation is a graceful no-op in the preview and runs on device via
the QR tab — the same web-preview-vs-device split as every Sparkling example.

Docs updated to reference the go-web preview instead of the (dropped)
sparkling-web-shell harness.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1EU55YA4e1zj2oVHh7Zme
The live Vercel deploy showed no web preview for the TanStack Router
examples: the demo's `build:web` was failing during the site build, so
prepare-examples wrote metadata with no `webFile` and copied no bundles
(the *.web.bundle 404s on the deploy, while the playground's serve fine).

Root cause: the demo's web build did two things the (Vercel-proven)
playground build does not — a `pnpm codegen` step and the
`@tanstack/router-plugin` generator in `tools.rspack` — both running extra
build-time code. Neither is needed at deploy time: the generated
`routeTree.gen.ts` / `routes.manifest.ts` / `page-entries.gen.ts` are
committed, and the native `build`/`dev` scripts still run codegen + the
plugin (so the compile-time toolchain composition is still exercised).

- lynx.web.config.ts: drop `tools` (the router-plugin), scope entries to
  the two surfaced pages (spike, home) — the web build is now plain rspeedy
  + pluginReactLynx, matching the playground.
- build:web: drop the `pnpm codegen &&` prefix; build straight from the
  committed generated files.

Verified: a clean `prepare-examples` run (the Vercel path) builds the demo
and emits `webFile` for spike and home.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1EU55YA4e1zj2oVHh7Zme
The actual reason the TanStack Router example had no web preview on the
deploy: `build:web` did not build the demo's workspace dependencies
(`sparkling-method`, `sparkling-navigation`, `sparkling-history`) first.
Their `dist/` is gitignored, so on a fresh Vercel checkout rspeedy could
not resolve them — the build failed with "Module not found: Can't resolve
'sparkling-method'", prepare-examples swallowed the error, and the example
shipped metadata with no `webFile` and no bundles (404 on the deploy).

It worked locally only because those packages had been built earlier in
the workspace. The playground avoids this by building its own method
packages first (`--filter sparkling-method ... build && rspeedy build`);
this mirrors that.

Verified from a clean state (deps' dist + tsbuildinfo removed, matching a
fresh checkout): build:web now builds the three deps, then emits
spike.web.bundle + home.web.bundle, and prepare-examples produces the
`webFile` entries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1EU55YA4e1zj2oVHh7Zme
…preview

A go-web `<Go>` card renders a single bundle and cannot swap to another
native page, so cross-page `router.open` calls were dead in the web preview
(the demo doc told readers to scan the QR code instead). Route them through
the Sparkling web bridge back into the same card and render the destination
route in-page — the bundle already carries the whole route tree, so
Detail/Settings now navigate live in the browser while a real device keeps
the MPA split (each page its own native LynxView).

- src/web-nav/setup.ts (native no-op) + setup.web.ts (web): the web variant
  imports `sparkling-navigation/web` to register the `router.open`/`close`
  web methods and points their pluggable host (`setRouterWebHost`) at this
  card, driving the router's own history in-page. Selected via the
  React-Native-style `.web.ts` platform extension.
- lynx.web.config.ts: add `.web.ts(x)` to `resolve.extensions` so the web
  build picks setup.web.ts over the native stub.
- create-router.tsx: wire setupWebNav; add an in-card re-entrancy guard so a
  bridged open bypasses the page manifest (renders in-page) instead of
  re-dispatching to the host and looping.
- docs: the MPA example page now explains the in-page bridge behavior and
  points Detail tiktok#42 at a live tap (params reconstructed from the scheme).

Verified live in the `<Go>` preview: Home -> /detail/42?ref=home (params via
scheme) -> /detail/43?ref=detail (in-page) -> Back (router.close).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1EU55YA4e1zj2oVHh7Zme
…navigation

Navigation in the demo was an instant swap. Add a page-enter animation so both
in-page and (bridged) cross-page navigation slide in: forward opens from the
right, back pops from the left — mimicking a native push/pop.

- src/transitions.css: plain Lynx CSS @Keyframes (translateX + opacity). Lynx
  runs these natively, so they play in the go-web `<Go>` web preview and on
  device (each page's content animates in as its LynxView mounts).
- src/ui.tsx: PageTransition wraps the router <Outlet> in a keyed <view> that
  remounts per location, replaying the animation. Direction comes from the
  history stack index (__TSR_index). It is derived in module scope (one router
  per JS context in the MPA model) rather than via a hook, which keeps ui.tsx
  importable in the DOM/Lynx-free vitest env (tests/generated-tree.test.ts), and
  cached per destination key so a double render before commit can't misreport it.
- src/routes/__root.tsx: render PageTransition as the root component.
- docs: note the push/pop animation on the MPA example page (EN + ZH).

Verified live in the `<Go>` preview by sampling the frame's transform/opacity
through a navigation: forward slides translateX 30px->0 with opacity 0->1 over
280ms; back slides from -30px. Native build + all 16 demo tests still pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1EU55YA4e1zj2oVHh7Zme
…ner animation

Page-transition animation is a container concern, not the app's. The previous
commit hand-rolled a push/pop slide in the demo (a PageTransition component +
transitions.css) — that simulates at the wrong layer. There is no built-in page
transition anywhere in sparkling's web path (web-shell hard-swaps the
<lynx-view>; the router.open/close web bridge only does pushState/back), so the
only real navigation-animation primitive is the native container's `animated`
flag. Use that instead of faking one.

- Revert the demo PageTransition, transitions.css, and the doc note.
- createSparklingHost: pass `animated` through to sparkling-navigation's
  open/close so the *native container* runs its push/pop. New `animated` host
  option (default true); a per-call close({ animated }) still overrides it.
- docs: explain that the transition is the container's job and the go-web web
  preview (in-page render) does not reproduce it — the swap there is instant.

The web preview keeps rendering cross-page destinations in-card (unchanged);
it just no longer animates. Verified navigation still works post-revert
(Home -> /detail/42?ref=home renders in-card). 29 history + 16 demo tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1EU55YA4e1zj2oVHh7Zme

@Huxpro Huxpro left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

整体 review 结论:方向和分层设计是对的,价值很高——NavigationHost 抽象、MPA history 语义(跨页不进本地栈、back() 触底转 native pop)、manifest 最长前缀匹配、复用官方 generator 只补 MPA codegen,这些都值得保留并继续演进。但有两类必须在合入前处理的问题,详见行内评论。

高危(阻塞)

  1. native 真机路径大概率跑不通且从未被验证sparkling-history host 的 new URL() 与 TanStack 依赖的 URLSearchParams 在 Lynx 原生 runtime(PrimJS)无 polyfill(官方指南要求的 url-search-params-polyfill 未引入);Node 测试与浏览器/go-web 预览恰好都自带这些 API,掩盖了缺口。建议把"真机原生跑通 MPA demo"列为合入验收项。
  2. PR 描述与 diff 严重不符:正文声称包含 sparkling-web-shell、web method 实现(storage/media)、CLI 的 dev:web/run:web/doctor、create-sparkling-app--web 支持——这些均不在本 PR 的 54 个变更文件中(实际只有 docs、sparkling-historytanstack-router-demo、website 脚本与 lockfile)。请重写描述以匹配实际内容。

  • deep-link 缺 __mpa_href 时回退 '/',会在错误的容器里渲染 home UI(行内有详述与修法)。
  • 手写 react/react-dom shim 应换官方 @lynx-js/react/compat;TanStack 三个包版本应同步 pin。
  • 每个 bundle 打包整棵 route tree 的简化应在文档中明示为非最终方案。

animated 注释与 native 现状不符、gen-mpa 的 regex+eval 提取脆弱、__mpa_state 的 URL 长度/序列化约束无守卫、blocker 无法拦截容器侧返回(注释已声明,建议升格到文档限制清单)。

另外指出一个后续演进方向(不阻塞本 PR):NavigationHost 目前只有命令面(open/close),没有事件/订阅面——native 栈变化(手势返回、外部 deep-link)无法回流给 JS 侧收敛状态,result 回传也没有通道。这会是这套架构走向完整"JS 驱动 native 导航"的下一个主接口变更。


Generated by Claude Code

Comment thread packages/sparkling-history/src/hosts/sparkling.ts Outdated
Comment thread packages/sparkling-history/src/hosts/sparkling.ts
Comment thread packages/tanstack-router-demo/src/shims/env.ts
Comment thread packages/tanstack-router-demo/lynx.config.ts
Comment thread packages/tanstack-router-demo/scripts/gen-mpa.mjs
Comment thread packages/sparkling-history/src/hosts/sparkling.ts
…esults)

Review fixes:
- sparkling host: build schemes by hand instead of new URL() — the native
  Lynx runtime has no URL/URLSearchParams globals, and this is the hot path
  of every cross-page navigation; encoding is now %20-safe by construction
- demo: import url-search-params-polyfill (required by @tanstack/react-router
  itself, per the official Lynx guide) in shims/env.ts
- deep links: a bundle opened without __mpa_href now renders its own page's
  default route (manifest defaultHref + defaultHrefForPage), not the app root
- gen-mpa: fail loudly when a `page` export exists in a shape the generator
  cannot parse, instead of silently mis-filing the route into the root page
- pin @tanstack/router-generator and router-plugin exactly, matching the
  pinned @tanstack/react-router
- demo build script now builds workspace deps first (native build was broken
  without a prior build:web)
- correct the animated-option comment: no native support yet on either
  platform; docs updated accordingly

Protocol groundwork (stack events / result channel):
- NavigationHost grows an optional event face: subscribeStack broadcasting
  StackChangedEvent (push/pop/replace/container-back, depth, pop result)
- history.closePage({ result }) closes the container handing a result to the
  page below; runs blockers like any JS-initiated navigation
- createStackMirror(host): read-only, uSES-compatible native-stack snapshot
  with live/degraded feature detection
- memory host implements the full protocol as its executable specification
  (tests/stack-events.test.ts); the sparkling binding stays command-only
  until the native SDK broadcasts stack changes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wEPNgmHbba2Bgkr1KcDYa
@Huxpro Huxpro changed the title Add web platform support and TanStack Router MPA navigation TanStack Router driving native MPA navigation (sparkling-history + demo) Jul 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants