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
116 changes: 96 additions & 20 deletions docs/en/guide/tanstack-router.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,11 +121,18 @@ globals**, so the host must not depend on them.
(`defaultHref`, derived from the manifest via `defaultHrefForPage`) — never
to `/`, which would render the root page's UI inside the wrong container.

**Note on bundle size (demo simplification)**: every page bundle currently
carries the *full* route tree — the generator emits per-page entries but does
not yet prune each page's subtree, so bundle size grows linearly with page
count. Subtree splitting per page boundary is the next step for the MPA
codegen, not a property of the design.
**Per-page tree pruning**: each bundle boots a *pruned* route tree
(`src/pages.gen/<id>/routeTree.ts`): its own routes carry full options
(components, loaders, `validateSearch`), while every foreign route is a
path-only stub kept solely so `navigate({ to, params })` can build an href —
which the manifest resolver then dispatches to the host; a stub never
renders. The **full** `routeTree.gen.ts` remains the type-level source of
truth, so cross-page links stay type-checked against the whole app. The
runtime (`mount`/`createMpaRouter`) deliberately imports no default tree —
a static default would drag every page's components back into every bundle.
What pruning does **not** shrink is the per-bundle framework baseline
(ReactLynx + router core + shims, ~330 kB in this demo); deduplicating that
across bundles is shared-chunk work at the Lynx level, orthogonal to routing.

### File-based route manifest

Expand All @@ -136,17 +143,27 @@ with a page dimension. `createManifestPageResolver(manifest)` builds the

```ts
const manifest = {
version: 1, // schema version — the JS↔native contract
scheme: { base: 'hybrid://lynxview_page' }, // host scheme base
pages: [
{ id: 'home', paths: ['/', '/profile'] }, // one bundle, two routes
{ id: 'home', paths: ['/', '/profile'] }, // one bundle, two routes
{ id: 'detail', paths: ['/detail'], containerParams: { title: 'Detail' } },
{ id: 'settings', paths: ['/settings'] },
{ id: 'settings', paths: ['/settings'], presentation: 'modal' },
],
};
```

The longest matching path prefix wins; if the destination page equals the
current page, navigation stays in-page.

Schema v1 notes: the manifest is **pure serializable data** — it is the
contract shared by JS, codegen, and (eventually) the native side, so it is
versioned; consumers must reject versions they don't understand rather than
guess. `presentation: 'push' | 'modal'` declares how the destination
container is presented; the sparkling host encodes it into the scheme
(`presentation=modal`) — native support is part of the stack-protocol work,
and containers that don't understand it fall back to push.

## File-based routing: reusing TanStack's compile-time toolchain

TanStack Router's file-based routing has a **compile-time half** that is fully
Expand Down Expand Up @@ -180,21 +197,37 @@ artifacts derived from the **same** route files:
2. one bundle entry per native page (`src/pages.gen/<id>/index.tsx`), wired into
`source.entry`.

Page boundaries are declared inline in a route file — our extension to the
convention:
Page boundaries are declared two ways — our extension to the convention:

```ts
// 1. In-file, in a route file:
// src/routes/detail.$id.tsx
export const page = { id: 'detail', containerParams: { title: 'Detail' } };
export const Route = createFileRoute('/detail/$id')({ /* ... */ });

// 2. Per-directory, claiming every route in the directory (and below):
// src/routes/feed/-container.ts
export const container = { id: 'feed', containerParams: { title: 'Feed' } };
```

Routes without a `page` export belong to the root page (the one whose `page`
has `root: true`). `gen-mpa.mjs` reads these markers and emits the manifest and
entries; `createManifestPageResolver` consumes the manifest at runtime. The
whole pipeline (`pnpm codegen`) is wired into build/dev/pretest, so
`routeTree.gen.ts` + `routes.manifest.ts` + entries regenerate from the route
files alone.
The boundary file is prefixed **`-`** deliberately: the official generator
*excludes* `-`-prefixed files from routing, while a `_` prefix would create a
pathless layout route and corrupt the generated tree. An in-file `page`
export overrides an inherited directory marker (an explicit carve-out, e.g. a
modal page inside another page's path space).

Marker extraction is **TypeScript-AST based** (no regex, no eval): markers
must be statically evaluable literals (`satisfies`-wrapped is fine), and any
unreadable form — re-export, referenced constant, computed value — fails the
build. Codegen also enforces **boundary containment**: a route *without* a
marker that sits under another page's declared path prefix would render in a
surprising container, so it is a build error (move the file, or mark it).

Routes without any marker belong to the root page (the one whose marker has
`root: true`). `gen-mpa.mjs` reads the markers and emits the manifest,
pruned per-page trees, and entries; `createManifestPageResolver` consumes the
manifest at runtime. The whole pipeline (`pnpm codegen`) is wired into
build/dev/pretest, so everything regenerates from the route files alone.

## What a navigation actually does

Expand Down Expand Up @@ -428,16 +461,59 @@ navigation; ours optimizes for native-owned, isolated page containers. The
`sparkling-history` layer is what makes the router core reusable in the MPA
world their adapter does not target.

## Second authoring frontend: the Next-style app directory

The runtime consumes exactly three inputs — a route tree, a page manifest, and
the booting page's id. Nothing in it knows which file convention produced the
first two. That makes authoring a *frontend* concern: a translator from a file
convention to the artifact pair. The demo ships two:

| | TanStack convention | Next-style convention |
|---|---|---|
| Source | `src/routes/*` (flat files) | `src/app/**` (`page.tsx`, `layout.tsx`, `[param]/`) |
| Route component | `createFileRoute(...)({ component })` | `export default` |
| Page boundary | `export const page = {...}` | `export const container = {...}` |
| Extra route options | inline in `createFileRoute` | `export const routeOptions = {...}` |
| Translator | official generator + `gen-mpa.mjs` | `gen-next.mjs` |
| Artifacts | `routeTree.gen.ts` + `routes.manifest.ts` | `routeTree.next.gen.ts` + `routes-next.manifest.ts` |

`gen-next.mjs` walks the app directory, emits thin *bridge* route files
(TanStack flat convention, re-exporting the app components and translating
`container` → `page`), runs the **official** TanStack generator over them, and
compiles the manifest with the same shared compiler (`lib/page-manifest.mjs`)
the TanStack frontend uses. `routeOptions` (e.g. `validateSearch`) spreads
through as code, since functions cannot be compiled into a data manifest.

`tests/next-parity.test.ts` pins the equivalence: identical route-id sets,
deep-equal manifests, and the same cross-page/in-page navigation behavior over
the same runtime — `mount({ pageId, routeTree, manifest })` takes the artifact
pair explicitly (no defaults: a static default import would pull the full tree
into every bundle); no runtime logic changed.

**Status: experimental.** The Next-style frontend exists to prove the
authoring dimension is pluggable; it is not part of the v1 API surface, and
shipping two conventions would split the ecosystem. It also skips per-page
tree pruning (its entries boot the full next tree). Both frontends build side
by side here (`next-*` bundles) purely for the demo; a real app would pick
one convention and ship it unprefixed. The surface is convention-compatible,
not Next-compatible: server components, data fetching, and the rest of Next's
runtime semantics are out of scope.

## Packages & files

- `packages/sparkling-history` — the reusable shim (contract + history +
sparkling host + manifest resolver). 28 tests.
sparkling host + manifest resolver + stack mirror). 40 tests.
- `packages/tanstack-router-demo` — the spike, the file-based multi-page MPA
demo, and the headless tests (16: feature matrix + generated-tree).
demo (two authoring frontends), and the headless tests (35: feature matrix +
generated-tree + next-parity).
- `src/routes/*` — file-based routes (official convention + `page` markers).
- `scripts/codegen.mjs` — runs the official generator + `gen-mpa.mjs`.
- `scripts/gen-mpa.mjs` — MPA manifest + per-page entries codegen.
- `src/app/**` — the same app authored Next-style (`container` markers).
- `scripts/codegen.mjs` — runs the official generator + `gen-mpa.mjs` +
`gen-next.mjs`.
- `scripts/gen-mpa.mjs` / `scripts/gen-next.mjs` — the two frontend
translators; `scripts/lib/page-manifest.mjs` — shared manifest compiler.
- `src/routeTree.gen.ts` (official generator), `src/routes.manifest.ts` +
`src/page-entries.gen.ts` + `src/pages.gen/*` (MPA codegen).
`src/page-entries.gen.ts` + `src/pages.gen/*` (MPA codegen), and their
`*.next.*` counterparts from the Next-style translator.
- Website embed: `docs/en/guide/examples/tanstack-router.mdx` (the live `<Go>`
example), registered in `packages/website/scripts/prepare-examples.mjs`.
7 changes: 7 additions & 0 deletions packages/sparkling-history/src/hosts/sparkling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,13 @@ export function createSparklingHost(options: SparklingHostOptions): NavigationHo
function buildScheme(target: HostOpenTarget, depth: number): string {
const pairs: Array<[string, string]> = [['bundle', bundleForPage(target.page.id)]];

// Container presentation (manifest v1). Transported as a scheme param;
// the native container decides what `modal` means. Pending native
// support, containers that don't understand it fall back to push.
if (target.page.presentation && target.page.presentation !== 'push') {
pairs.push(['presentation', target.page.presentation]);
}

// Static container config resolved before the page boots.
for (const [key, value] of Object.entries(target.page.containerParams ?? {})) {
pairs.push([key, value]);
Expand Down
18 changes: 17 additions & 1 deletion packages/sparkling-history/src/resolve-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ export interface PageManifestEntry {
* by a more specific page).
*/
paths: Array<string>;
/** Container presentation. Omitted = `push`. See {@link PageTarget}. */
presentation?: 'push' | 'modal';
/** Static container config applied when opening this page. */
containerParams?: Record<string, string>;
/**
Expand All @@ -25,9 +27,22 @@ export interface PageManifestEntry {
/**
* A file-based route manifest: the pre-generated metadata that connects
* routes (which live in separate JS contexts and cannot share memory) to
* native pages. This is the artifact a codegen step would emit.
* native pages. This is the artifact the codegen step emits (schema v1).
*
* The manifest is the JS↔native contract: it must stay serializable so the
* native side (and other frontends) can consume the same file.
*/
export interface PageManifest {
/**
* Manifest schema version. Omitted = pre-v1 (accepted; treated as v1).
* Consumers must reject versions they do not understand rather than guess.
*/
version?: 1;
/** Scheme configuration for hosts that open pages via URL schemes. */
scheme?: {
/** Base scheme for page opens, e.g. `hybrid://lynxview_page`. */
base: string;
};
pages: Array<PageManifestEntry>;
}

Expand Down Expand Up @@ -92,6 +107,7 @@ export function createManifestPageResolver(manifest: PageManifest): PageResolver
// Same page → in-page transition.
if (currentPage && destPage.id === currentPage.id) return null;
const target: PageTarget = { id: destPage.id };
if (destPage.presentation) target.presentation = destPage.presentation;
if (destPage.containerParams) target.containerParams = destPage.containerParams;
return target;
};
Expand Down
7 changes: 7 additions & 0 deletions packages/sparkling-history/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,13 @@ export interface MpaHistory {
*/
export interface PageTarget {
id: string;
/**
* How the destination container is presented. `push` (default) stacks a
* full page; `modal` presents over the current one. Transported to the
* host; native support is part of the stack-protocol work — hosts without
* it treat every open as `push`.
*/
presentation?: 'push' | 'modal';
/**
* Static container configuration resolved *before* the target page's JS
* boots (title, nav bar, orientation, ... — sparkling scheme params).
Expand Down
25 changes: 25 additions & 0 deletions packages/sparkling-history/tests/sparkling-host.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,31 @@ describe('createSparklingHost', () => {
expect(JSON.parse(url.searchParams.get('__mpa_state')!)).toEqual({ scrollTo: 10 });
});

test('manifest v1 presentation is encoded into the scheme (modal only)', () => {
const navigation = fakeNavigation();
const host = createSparklingHost({ navigation, getQueryItems: () => ({}) });
const v1: PageManifest = {
version: 1,
scheme: { base: 'hybrid://lynxview_page' },
pages: [
{ id: 'main', paths: ['/'] },
{ id: 'settings', paths: ['/settings'], presentation: 'modal' },
],
};
const history = createMpaHistory({ host, resolvePage: createManifestPageResolver(v1) });
history.push('/settings');
const url = new URL(navigation.openCalls[0]!.scheme);
expect(url.searchParams.get('presentation')).toBe('modal');
// push (the default) is never encoded — absence means push.
const history2 = createMpaHistory({
host: createSparklingHost({ navigation, getQueryItems: () => ({}) }),
resolvePage: createManifestPageResolver(manifest),
});
history2.push('/detail/1');
const url2 = new URL(navigation.openCalls[1]!.scheme);
expect(url2.searchParams.get('presentation')).toBeNull();
});

test('replace passes options.replace to sparkling open (animated by default)', () => {
const navigation = fakeNavigation();
const host = createSparklingHost({ navigation, getQueryItems: () => ({}) });
Expand Down
22 changes: 16 additions & 6 deletions packages/tanstack-router-demo/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,24 @@ metadata (a route→page manifest), because they cannot share a JS heap.

- `src/spike/` — the minimal feasibility spike: TanStack Router on a memory
history in a single Lynx view (proves the router runs on ReactLynx at all).
- `src/mpa/routes.tsx` — the shared route tree + the page manifest that maps
routes to native pages (`home` owns `/` and `/profile`; `detail` owns
`/detail`; `settings` owns `/settings`).
- `src/routes/*` — file-based routes (official TanStack convention) with
`export const page` markers declaring native-page boundaries (`home` owns `/`
and `/profile`; `detail` owns `/detail`; `settings` owns `/settings`).
- `src/app/**` — the SAME app authored with the Next-style app-directory
convention (`page.tsx`, `layout.tsx`, `[param]/`, `export const container`
markers). `scripts/gen-next.mjs` translates it to the same artifact pair
(route tree + manifest); `tests/next-parity.test.ts` pins the equivalence.
- `scripts/` — `codegen.mjs` (runs everything), `gen-mpa.mjs` +
`gen-next.mjs` (the two frontend translators), `lib/page-manifest.mjs`
(shared manifest compiler).
- `src/mpa/create-router.tsx` — wires `createRouter` to `sparkling-navigation`
through `sparkling-history` (`createMpaHistory` + `createSparklingHost`).
- `src/mpa/mount.tsx` + `src/pages/{home,detail,settings}/index.tsx` — one
bundle entry per page. Every entry boots the same router; each derives its
start location from its launch `queryItems` (`__mpa_href`).
- `src/mpa/mount.tsx` + generated `src/pages.gen/*` / `src/pages-next.gen/*` —
one bundle entry per page. Every entry boots the same runtime with
`mount({ pageId, routeTree, manifest })`; each derives its start location
from its launch `queryItems` (`__mpa_href`). TanStack-frontend entries boot
a **pruned** per-page tree (`pages.gen/<id>/routeTree.ts`): own routes with
full options, foreign routes as path-only href stubs.
- `src/shims/` — the bundler-level shims that let TanStack Router run on
ReactLynx (see below).

Expand Down
8 changes: 8 additions & 0 deletions packages/tanstack-router-demo/lynx.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { pluginReactLynx } from '@lynx-js/react-rsbuild-plugin';
import { TanStackRouterGeneratorRspack } from '@tanstack/router-plugin/rspack';
// Generated by scripts/gen-mpa.mjs — one bundle entry per native page.
import { pageEntries } from './src/page-entries.gen.js';
// Generated by scripts/gen-next.mjs — same pages, authored Next-style.
import { nextPageEntries } from './src/page-entries-next.gen.js';

const __dirname = path.dirname(fileURLToPath(import.meta.url));

Expand All @@ -19,6 +21,12 @@ export default defineConfig({
// MPA demo: generated page entries (home / detail / settings), each a
// separate bundle / JS context.
...pageEntries,
// Next-style frontend (RFC phase 3): the same pages compiled from the
// src/app directory. Prefixed so both frontends can build side by side
// in this demo; a real app would ship one frontend, unprefixed.
...Object.fromEntries(
Object.entries(nextPageEntries).map(([id, entry]) => [`next-${id}`, entry]),
),
},
},
resolve: {
Expand Down
2 changes: 1 addition & 1 deletion packages/tanstack-router-demo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"build": "pnpm -C ../.. --filter sparkling-method --filter sparkling-navigation --filter sparkling-history build && pnpm codegen && rspeedy build",
"build:web": "pnpm -C ../.. --filter sparkling-method --filter sparkling-navigation --filter sparkling-history build && rspeedy build --config lynx.web.config.ts",
"dev": "pnpm codegen && rspeedy dev",
"pretest": "pnpm codegen",
"pretest": "pnpm -C ../.. --filter sparkling-history build && pnpm codegen",
"test": "vitest run --passWithNoTests"
},
"dependencies": {
Expand Down
6 changes: 5 additions & 1 deletion packages/tanstack-router-demo/scripts/codegen.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,8 @@ await new Generator({ config, root }).run();
const here = dirname(fileURLToPath(import.meta.url));
execFileSync(process.execPath, [join(here, 'gen-mpa.mjs')], { stdio: 'inherit', cwd: root });

console.log('codegen: routeTree.gen.ts + routes.manifest.ts + page entries written');
// 3. The Next-style frontend translator (RFC phase 3): same artifacts from the
// app-directory convention.
execFileSync(process.execPath, [join(here, 'gen-next.mjs')], { stdio: 'inherit', cwd: root });

console.log('codegen: routeTree(.next).gen.ts + manifests + page entries written');
Loading
Loading