diff --git a/docs/en/guide/tanstack-router.md b/docs/en/guide/tanstack-router.md index 8c82b687..5318b24a 100644 --- a/docs/en/guide/tanstack-router.md +++ b/docs/en/guide/tanstack-router.md @@ -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//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 @@ -136,10 +143,12 @@ 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' }, ], }; ``` @@ -147,6 +156,14 @@ const manifest = { 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 @@ -180,21 +197,37 @@ artifacts derived from the **same** route files: 2. one bundle entry per native page (`src/pages.gen//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 @@ -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 `` example), registered in `packages/website/scripts/prepare-examples.mjs`. diff --git a/packages/sparkling-history/src/hosts/sparkling.ts b/packages/sparkling-history/src/hosts/sparkling.ts index aaebd458..94b70ddb 100644 --- a/packages/sparkling-history/src/hosts/sparkling.ts +++ b/packages/sparkling-history/src/hosts/sparkling.ts @@ -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]); diff --git a/packages/sparkling-history/src/resolve-page.ts b/packages/sparkling-history/src/resolve-page.ts index 3b04f906..cf9a55b6 100644 --- a/packages/sparkling-history/src/resolve-page.ts +++ b/packages/sparkling-history/src/resolve-page.ts @@ -12,6 +12,8 @@ export interface PageManifestEntry { * by a more specific page). */ paths: Array; + /** Container presentation. Omitted = `push`. See {@link PageTarget}. */ + presentation?: 'push' | 'modal'; /** Static container config applied when opening this page. */ containerParams?: Record; /** @@ -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; } @@ -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; }; diff --git a/packages/sparkling-history/src/types.ts b/packages/sparkling-history/src/types.ts index dc2227ea..abde1e54 100644 --- a/packages/sparkling-history/src/types.ts +++ b/packages/sparkling-history/src/types.ts @@ -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). diff --git a/packages/sparkling-history/tests/sparkling-host.test.ts b/packages/sparkling-history/tests/sparkling-host.test.ts index fcf8854f..03ec38d9 100644 --- a/packages/sparkling-history/tests/sparkling-host.test.ts +++ b/packages/sparkling-history/tests/sparkling-host.test.ts @@ -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: () => ({}) }); diff --git a/packages/tanstack-router-demo/README.md b/packages/tanstack-router-demo/README.md index 2a53d29a..2c6d3cfd 100644 --- a/packages/tanstack-router-demo/README.md +++ b/packages/tanstack-router-demo/README.md @@ -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//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). diff --git a/packages/tanstack-router-demo/lynx.config.ts b/packages/tanstack-router-demo/lynx.config.ts index 73f6c347..64d3c6fc 100644 --- a/packages/tanstack-router-demo/lynx.config.ts +++ b/packages/tanstack-router-demo/lynx.config.ts @@ -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)); @@ -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: { diff --git a/packages/tanstack-router-demo/package.json b/packages/tanstack-router-demo/package.json index ff615b49..c08fbe35 100644 --- a/packages/tanstack-router-demo/package.json +++ b/packages/tanstack-router-demo/package.json @@ -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": { diff --git a/packages/tanstack-router-demo/scripts/codegen.mjs b/packages/tanstack-router-demo/scripts/codegen.mjs index 68f78d0c..525d2ae1 100644 --- a/packages/tanstack-router-demo/scripts/codegen.mjs +++ b/packages/tanstack-router-demo/scripts/codegen.mjs @@ -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'); diff --git a/packages/tanstack-router-demo/scripts/gen-mpa.mjs b/packages/tanstack-router-demo/scripts/gen-mpa.mjs index ef92e6cc..940b18b0 100644 --- a/packages/tanstack-router-demo/scripts/gen-mpa.mjs +++ b/packages/tanstack-router-demo/scripts/gen-mpa.mjs @@ -5,162 +5,119 @@ // MPA codegen — the piece TanStack Router's own generator does not provide. // // TanStack's @tanstack/router-generator turns the file convention into a route -// tree (routeTree.gen.ts), assuming one router / one bundle. An MPA needs two -// more artifacts derived from the SAME route files: +// tree (routeTree.gen.ts), assuming one router / one bundle. An MPA needs +// three more artifacts derived from the SAME route files: // -// 1. src/routes.manifest.ts — the route -> native-page (bundle) mapping -// 2. src/pages.gen//index.tsx — one bundle entry per native page +// 1. src/routes.manifest.ts — route -> native-page mapping (v1) +// 2. src/pages.gen//routeTree.ts — PRUNED route tree per page: +// own routes carry full options (component, loaders, validateSearch); +// foreign routes become path-only stubs kept solely so cross-page +// `navigate({ to, params })` can still build hrefs (which the manifest +// resolver then dispatches to the host — a stub never renders). +// 3. src/pages.gen//index.tsx + src/page-entries.gen.ts — entries. // -// Page boundaries are declared with an `export const page = { id, ... }` in a -// route file (our extension to the convention). Routes without a `page` export -// belong to the root page (the one whose `page` has `root: true`). -import { readdirSync, readFileSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'; +// The FULL tree (routeTree.gen.ts) remains the type-level source of truth — +// cross-page links stay type-checked against the whole app. Pruning is a +// build-artifact concern only. +import { writeFileSync, mkdirSync, rmSync } from 'node:fs'; import { join, resolve } from 'node:path'; +import { collectTanstackRoutes } from './lib/collect-routes.mjs'; +import { buildPages, renderManifestModule } from './lib/page-manifest.mjs'; const ROOT = resolve(process.cwd()); const ROUTES_DIR = join(ROOT, 'src', 'routes'); const MANIFEST_OUT = join(ROOT, 'src', 'routes.manifest.ts'); const ENTRIES_DIR = join(ROOT, 'src', 'pages.gen'); -/** Derive a route path from a TanStack file name (fallback if no explicit arg). */ -function pathFromFileName(file) { - let name = file.replace(/\.(tsx?|jsx?)$/, ''); - if (name === 'index') return '/'; - // `detail.$id` -> `/detail/$id`, `a.b` -> `/a/b` - return '/' + name.split('.').join('/'); -} - -/** Extract the createFileRoute('...') path argument, if present. */ -function extractRoutePath(src, file) { - const m = src.match(/createFileRoute\(\s*['"]([^'"]+)['"]\s*\)/); - return m ? m[1] : pathFromFileName(file); -} +const HEADER = `// AUTO-GENERATED by scripts/gen-mpa.mjs — do not edit.\n`; -/** Extract and evaluate the `export const page = { ... }` object literal. */ -function extractPage(src, file) { - const idx = src.search(/export\s+const\s+page\s*=\s*\{/); - if (idx === -1) { - // A `page` export in any other shape (satisfies, referenced constant, - // re-export) would be silently mis-filed into the root page — fail loud. - if (/export\s+(const|let|var)\s+page\b/.test(src) || /export\s*\{[^}]*\bpage\b/.test(src)) { - throw new Error( - `gen-mpa: ${file} exports \`page\` in a form this generator cannot parse. ` + - `Use a plain object literal: export const page = { id: '...', ... }`, - ); - } - return undefined; - } - const braceStart = src.indexOf('{', idx); - // Brace-match to find the end of the object literal. - let depth = 0; - let end = -1; - for (let i = braceStart; i < src.length; i++) { - const c = src[i]; - if (c === '{') depth++; - else if (c === '}') { - depth--; - if (depth === 0) { - end = i; - break; - } - } - } - if (end === -1) return undefined; - const objText = src.slice(braceStart, end + 1); - // Build-time eval of a literal from our own trusted source file. - // eslint-disable-next-line no-new-func - return Function(`"use strict"; return (${objText});`)(); +function identFor(rel) { + return 'R_' + rel.replace(/\.(tsx?|jsx?)$/, '').replace(/[^a-zA-Z0-9]/g, '_'); } -/** The static path prefix a page owns (up to the first param segment). */ -function staticPrefix(routePath) { - const segs = routePath.split('/'); - const out = []; - for (const seg of segs) { - if (seg.startsWith('$') || seg === '') { - if (seg === '' && out.length === 0) out.push(''); // keep leading '/' - if (seg.startsWith('$')) break; - continue; - } - out.push(seg); - } - const prefix = out.join('/') || '/'; - return prefix === '' ? '/' : prefix; +function renderPrunedTree(page, routes, rootPageId) { + const own = routes.filter((r) => (r.page?.id ?? rootPageId) === page.id); + const foreign = routes.filter((r) => (r.page?.id ?? rootPageId) !== page.id); + + const imports = own + .map((r) => `import { Route as ${identFor(r.file)} } from '../../routes/${r.file.replace(/\.(tsx?|jsx?)$/, '.js')}';`) + .join('\n'); + + const ownDecls = own + .map( + (r) => + `const ${identFor(r.file)}_route = createRoute({\n` + + ` ...(${identFor(r.file)} as any).options,\n` + + ` path: '${r.path}',\n` + + ` getParentRoute: () => root,\n` + + `});`, + ) + .join('\n'); + + const stubDecls = foreign + .map( + (r, i) => + `// '${r.path}' lives in page '${r.page?.id ?? rootPageId}' — path-only stub.\n` + + `const stub_${i} = createRoute({ path: '${r.path}', getParentRoute: () => root });`, + ) + .join('\n'); + + const children = [ + ...own.map((r) => `${identFor(r.file)}_route`), + ...foreign.map((_, i) => `stub_${i}`), + ].join(', '); + + return ( + HEADER + + `// Pruned route tree for the '${page.id}' page: only this page's routes\n` + + `// carry components/options; every other route is a path-only stub for\n` + + `// href building. The full tree (routeTree.gen.ts) stays the type-level\n` + + `// source of truth.\n` + + `/* eslint-disable @typescript-eslint/no-explicit-any */\n` + + `import { createRootRoute, createRoute } from '@tanstack/react-router';\n` + + `import { Route as rootRouteImport } from '../../routes/__root.js';\n` + + (imports ? imports + '\n' : '') + + `\n` + + `// Fresh root cloned from the app root's options: pruned trees must not\n` + + `// mutate shared file-route singletons (the full tree links them too).\n` + + `const root = createRootRoute((rootRouteImport as any).options);\n\n` + + (ownDecls ? ownDecls + '\n' : '') + + (stubDecls ? stubDecls + '\n' : '') + + `\nexport const routeTree = root.addChildren([${children}]);\n` + ); } function main() { - const files = readdirSync(ROUTES_DIR).filter( - (f) => /\.(tsx?|jsx?)$/.test(f) && !f.startsWith('__'), - ); + const routes = collectTanstackRoutes(ROUTES_DIR); + const pages = buildPages(routes); + const rootPageId = routes.find((r) => r.page?.root)?.page.id; - const routes = files.map((file) => { - const src = readFileSync(join(ROUTES_DIR, file), 'utf8'); - return { file, path: extractRoutePath(src, file), page: extractPage(src, file) }; - }); + // 1. Manifest module (schema v1). + writeFileSync(MANIFEST_OUT, renderManifestModule(pages, 'scripts/gen-mpa.mjs')); - const rootPage = routes.find((r) => r.page?.root)?.page; - if (!rootPage) { - throw new Error('No root page found: exactly one route must export `page` with `root: true`.'); - } - - // Group route path-prefixes by page id. - /** @type {Map; containerParams?: Record; defaultHref?: string }>} */ - const byId = new Map(); - const ensure = (id, containerParams) => { - if (!byId.has(id)) byId.set(id, { id, paths: new Set(), containerParams }); - else if (containerParams && !byId.get(id).containerParams) byId.get(id).containerParams = containerParams; - return byId.get(id); - }; - - for (const r of routes) { - const pageId = r.page?.id ?? rootPage.id; // unmarked routes -> root page - const containerParams = r.page?.containerParams; - const entry = ensure(pageId, containerParams); - entry.paths.add(staticPrefix(r.path)); - // The declaring route (the one carrying the `page` export) is the page's - // deep-link default. Param segments can't be defaulted; use the static - // prefix of that route instead. - if (r.page) { - entry.defaultHref = r.path.includes('$') ? staticPrefix(r.path) : r.path; - } - } - - const pages = [...byId.values()].map((p) => ({ - id: p.id, - paths: [...p.paths].sort(), - ...(p.containerParams ? { containerParams: p.containerParams } : {}), - ...(p.defaultHref ? { defaultHref: p.defaultHref } : {}), - })); - - // 1. Write the manifest module. - const manifestBody = - `// AUTO-GENERATED by scripts/gen-mpa.mjs — do not edit.\n` + - `// Route -> native-page (bundle) mapping, derived from src/routes/*.\n` + - `import type { PageManifest } from 'sparkling-history';\n\n` + - `export const manifest: PageManifest = ${JSON.stringify({ pages }, null, 2)};\n`; - writeFileSync(MANIFEST_OUT, manifestBody); - - // 2. Write one entry per page. + // 2. Per-page pruned tree + entry. rmSync(ENTRIES_DIR, { recursive: true, force: true }); const entryList = []; for (const p of pages) { const dir = join(ENTRIES_DIR, p.id); mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'routeTree.ts'), renderPrunedTree(p, routes, rootPageId)); const entry = - `// AUTO-GENERATED by scripts/gen-mpa.mjs — do not edit.\n` + - `// Bundle entry for the '${p.id}' native page. Every page boots the same\n` + - `// router (from the generated route tree); its start location comes from\n` + - `// the launch queryItems, falling back to this page's default route when\n` + - `// opened by an external deep link.\n` + - `import { mount } from '../../mpa/mount.js';\n\n` + - `mount({ pageId: '${p.id}' });\n`; + HEADER + + `// Bundle entry for the '${p.id}' native page. Boots the shared runtime\n` + + `// with this page's pruned tree; start location comes from the launch\n` + + `// queryItems, falling back to this page's default route on deep link.\n` + + `import { mount } from '../../mpa/mount.js';\n` + + `import { routeTree } from './routeTree.js';\n` + + `import { manifest } from '../../routes.manifest.js';\n\n` + + `mount({ pageId: '${p.id}', routeTree, manifest });\n`; writeFileSync(join(dir, 'index.tsx'), entry); entryList.push({ id: p.id, entry: `./src/pages.gen/${p.id}/index.tsx` }); } - // 3. Emit an entries manifest the rspeedy config can spread into source.entry. + // 3. Entries manifest for the rspeedy config. const entriesBody = - `// AUTO-GENERATED by scripts/gen-mpa.mjs — do not edit.\n` + + HEADER + `export const pageEntries = ${JSON.stringify( Object.fromEntries(entryList.map((e) => [e.id, e.entry])), null, @@ -168,7 +125,10 @@ function main() { )};\n`; writeFileSync(join(ROOT, 'src', 'page-entries.gen.ts'), entriesBody); - console.log(`gen-mpa: ${pages.length} pages ->`, pages.map((p) => `${p.id}[${p.paths.join(',')}]`).join(' ')); + console.log( + `gen-mpa: ${pages.length} pages (pruned trees) ->`, + pages.map((p) => `${p.id}[${p.paths.join(',')}]`).join(' '), + ); } main(); diff --git a/packages/tanstack-router-demo/scripts/gen-next.mjs b/packages/tanstack-router-demo/scripts/gen-next.mjs new file mode 100644 index 00000000..3a02dd6d --- /dev/null +++ b/packages/tanstack-router-demo/scripts/gen-next.mjs @@ -0,0 +1,181 @@ +// 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. +// +// Next-style frontend translator (RFC phase 3 spike). +// +// Reads the Next-style app directory (src/app: layout.tsx, page.tsx, [param] +// segments) and compiles it to the SAME artifacts the TanStack-convention +// frontend produces — proving the runtime is frontend-agnostic: +// +// 1. src/routes-next.gen/ — TanStack route files (bridge modules) +// 2. src/routeTree.next.gen.ts — via the official @tanstack/router-generator +// 3. src/routes-next.manifest.ts — page manifest (shared lib/page-manifest.mjs) +// 4. src/pages-next.gen//index.tsx + src/page-entries-next.gen.ts +// +// Authoring markers in app files: +// export default — the route component (Next convention) +// export const container — native-page boundary (analogue of `page` in the +// TanStack convention; same shape, same manifest) +// export const routeOptions — extra createFileRoute options, spread through +// as code (e.g. validateSearch) +import { readdirSync, readFileSync, writeFileSync, mkdirSync, rmSync, existsSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { Generator, getConfig } from '@tanstack/router-generator'; +import { + extractExportedObjectLiteral, + buildPages, + renderManifestModule, +} from './lib/page-manifest.mjs'; + +const ROOT = resolve(process.cwd()); +const APP_DIR = join(ROOT, 'src', 'app'); +const ROUTES_OUT_DIR = join(ROOT, 'src', 'routes-next.gen'); +const TREE_OUT = './src/routeTree.next.gen.ts'; +const MANIFEST_OUT = join(ROOT, 'src', 'routes-next.manifest.ts'); +const ENTRIES_DIR = join(ROOT, 'src', 'pages-next.gen'); + +const HEADER = `// AUTO-GENERATED by scripts/gen-next.mjs — do not edit.\n`; + +/** Recursively collect page files: [{ segments: ['detail','[id]'], file }] */ +function collectPages(dir, segments = []) { + const out = []; + for (const name of readdirSync(dir, { withFileTypes: true })) { + if (name.isDirectory()) { + out.push(...collectPages(join(dir, name.name), [...segments, name.name])); + } else if (/^page\.(tsx?|jsx?)$/.test(name.name)) { + out.push({ segments, file: join(dir, name.name) }); + } + } + return out; +} + +/** `['detail','[id]']` -> `/detail/$id`; `[]` -> `/` */ +function routePathFromSegments(segments) { + if (segments.length === 0) return '/'; + return ( + '/' + + segments + .map((s) => { + const m = s.match(/^\[(.+)\]$/); + return m ? `$${m[1]}` : s; + }) + .join('/') + ); +} + +/** `/detail/$id` -> `detail.$id.tsx`; `/` -> `index.tsx` */ +function routeFileName(routePath) { + if (routePath === '/') return 'index.tsx'; + return routePath.slice(1).split('/').join('.') + '.tsx'; +} + +function main() { + const pageFiles = collectPages(APP_DIR); + if (pageFiles.length === 0) throw new Error(`gen-next: no page files under ${APP_DIR}`); + + const routes = pageFiles.map(({ segments, file }) => { + const src = readFileSync(file, 'utf8'); + return { + file, + relImport: ['..', 'app', ...segments, 'page.js'].join('/'), + path: routePathFromSegments(segments), + page: extractExportedObjectLiteral(src, 'container', file), + hasRouteOptions: /export\s+const\s+routeOptions\b/.test(src), + }; + }); + + // 1. Emit bridge route files (TanStack flat convention) into routes-next.gen. + rmSync(ROUTES_OUT_DIR, { recursive: true, force: true }); + mkdirSync(ROUTES_OUT_DIR, { recursive: true }); + + // Root route from app/layout.tsx (plain Outlet root if absent). + const hasLayout = existsSync(join(APP_DIR, 'layout.tsx')); + const rootBody = hasLayout + ? HEADER + + `import { createRootRoute, Outlet } from '@tanstack/react-router';\n` + + `import RootLayout from '../app/layout.js';\n\n` + + `export const Route = createRootRoute({\n` + + ` component: () => (\n` + + ` \n` + + ` \n` + + ` \n` + + ` ),\n` + + `});\n` + : HEADER + + `import { createRootRoute, Outlet } from '@tanstack/react-router';\n\n` + + `export const Route = createRootRoute({ component: () => });\n`; + writeFileSync(join(ROUTES_OUT_DIR, '__root.tsx'), rootBody); + + for (const r of routes) { + const imports = r.hasRouteOptions + ? `import Page, { routeOptions } from '${r.relImport}';\n` + : `import Page from '${r.relImport}';\n`; + const pageExport = r.page + ? `// Page boundary translated from \`export const container\` in the app file.\n` + + `export const page = ${JSON.stringify(r.page)};\n\n` + : ''; + const options = r.hasRouteOptions ? ` ...routeOptions,\n component: Page,\n` : ` component: Page,\n`; + const body = + HEADER + + `import { createFileRoute } from '@tanstack/react-router';\n` + + imports + + `\n` + + pageExport + + `export const Route = createFileRoute('${r.path}')({\n` + + options + + `});\n`; + writeFileSync(join(ROUTES_OUT_DIR, routeFileName(r.path)), body); + } + + // 2. Official TanStack generator over the bridge files -> routeTree.next.gen.ts. + const config = getConfig( + { + routesDirectory: './src/routes-next.gen', + generatedRouteTree: TREE_OUT, + }, + ROOT, + ); + const run = async () => { + await new Generator({ config: await config, root: ROOT }).run(); + }; + + // 3. Page manifest via the shared compiler — same artifact as gen-mpa's. + const pages = buildPages(routes); + writeFileSync(MANIFEST_OUT, renderManifestModule(pages, 'scripts/gen-next.mjs')); + + // 4. Per-page entries booting the SAME runtime with the next-frontend + // artifacts (mount is parameterized by routeTree/manifest, no new logic). + rmSync(ENTRIES_DIR, { recursive: true, force: true }); + const entryList = []; + for (const p of pages) { + const dir = join(ENTRIES_DIR, p.id); + mkdirSync(dir, { recursive: true }); + const entry = + HEADER + + `// Bundle entry for the '${p.id}' native page, next-style frontend.\n` + + `import { mount } from '../../mpa/mount.js';\n` + + `import { routeTree } from '../../routeTree.next.gen.js';\n` + + `import { manifest } from '../../routes-next.manifest.js';\n\n` + + `mount({ pageId: '${p.id}', routeTree, manifest });\n`; + writeFileSync(join(dir, 'index.tsx'), entry); + entryList.push({ id: p.id, entry: `./src/pages-next.gen/${p.id}/index.tsx` }); + } + const entriesBody = + HEADER + + `export const nextPageEntries = ${JSON.stringify( + Object.fromEntries(entryList.map((e) => [e.id, e.entry])), + null, + 2, + )};\n`; + writeFileSync(join(ROOT, 'src', 'page-entries-next.gen.ts'), entriesBody); + + return run().then(() => { + console.log( + `gen-next: ${pages.length} pages ->`, + pages.map((p) => `${p.id}[${p.paths.join(',')}]`).join(' '), + ); + }); +} + +await main(); diff --git a/packages/tanstack-router-demo/scripts/lib/collect-routes.mjs b/packages/tanstack-router-demo/scripts/lib/collect-routes.mjs new file mode 100644 index 00000000..2a38e4ad --- /dev/null +++ b/packages/tanstack-router-demo/scripts/lib/collect-routes.mjs @@ -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. +// +// Route collection for the TanStack file convention, with page-boundary +// resolution. Two ways to declare a boundary: +// +// 1. In-file: `export const page = { id, ... }` inside a route file. +// 2. Directory: a `-container.ts` file whose `export const container = {...}` +// claims every route in that directory (and below, unless overridden). +// +// The boundary file is prefixed `-` deliberately: the official TanStack +// generator EXCLUDES `-`-prefixed files from routing, while `_`-prefixed +// files become pathless layout routes. `_container.tsx` (as some prototypes +// used) would corrupt the generated route tree. +import { readdirSync, readFileSync, existsSync } from 'node:fs'; +import { join, relative, sep } from 'node:path'; +import { extractExportedObjectLiteral } from './page-manifest.mjs'; + +const ROUTE_FILE = /\.(tsx?|jsx?)$/; +const CONTAINER_BASENAME = /^-container\.(tsx?|jsx?)$/; + +function toPosix(p) { + return p.split(sep).join('/'); +} + +/** Derive a route path from a file path relative to the routes dir. */ +export function pathFromRelFile(rel) { + const segments = toPosix(rel) + .replace(ROUTE_FILE, '') + .split('/') + .flatMap((seg) => seg.split('.')) + .filter((seg) => seg !== 'index' && seg.length > 0); + return '/' + segments.join('/'); +} + +/** Extract the createFileRoute('...') path argument, if present. */ +function extractRoutePath(src, rel) { + const m = src.match(/createFileRoute\(\s*['"]([^'"]+)['"]\s*\)/); + return m ? m[1] : pathFromRelFile(rel); +} + +/** Nearest `-container.*` boundary marker, walking up to the routes root. */ +function nearestContainer(routesDir, dirRel, cache) { + const key = dirRel || '.'; + if (cache.has(key)) return cache.get(key); + let marker; + const dirAbs = join(routesDir, dirRel); + for (const ext of ['ts', 'tsx', 'js', 'jsx']) { + const candidate = join(dirAbs, `-container.${ext}`); + if (existsSync(candidate)) { + marker = extractExportedObjectLiteral(readFileSync(candidate, 'utf8'), 'container', candidate); + if (!marker) { + throw new Error(`collect-routes: ${candidate} must \`export const container = { ... }\`.`); + } + break; + } + } + if (!marker && dirRel) { + const parent = toPosix(dirRel).split('/').slice(0, -1).join('/'); + marker = nearestContainer(routesDir, parent, cache); + } + cache.set(key, marker); + return marker; +} + +/** + * Collect route files under `routesDir` with their paths and page markers. + * In-file `page` export wins over a directory `-container` marker. + * + * Throws on layout-route files: per-page tree pruning reconstructs each + * page's tree as flat children of the root route, which is exactly the + * demo's (and most MPAs') shape. Nested layout routes need hierarchy-aware + * pruning — fail loud rather than emit a wrong tree. + */ +export function collectTanstackRoutes(routesDir) { + const containerCache = new Map(); + const out = []; + const walk = (dirRel) => { + for (const entry of readdirSync(join(routesDir, dirRel), { withFileTypes: true })) { + const rel = dirRel ? `${dirRel}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + walk(rel); + continue; + } + if (!ROUTE_FILE.test(entry.name)) continue; + if (entry.name.startsWith('__')) continue; // __root + if (entry.name.startsWith('-')) continue; // generator-excluded (incl. -container) + if (entry.name.startsWith('_') || /(^|\.)route\.(tsx?|jsx?)$/.test(entry.name)) { + throw new Error( + `collect-routes: '${rel}' looks like a layout route. Per-page tree ` + + `pruning supports flat route files only for now — remove the layout ` + + `or disable pruning for this app.`, + ); + } + const abs = join(routesDir, rel); + const src = readFileSync(abs, 'utf8'); + const inFile = extractExportedObjectLiteral(src, 'page', abs); + const page = inFile ?? nearestContainer(routesDir, toPosix(dirRel), containerCache); + out.push({ file: rel, path: extractRoutePath(src, rel), page }); + } + }; + walk(''); + return out.sort((a, b) => a.path.localeCompare(b.path)); +} diff --git a/packages/tanstack-router-demo/scripts/lib/page-manifest.mjs b/packages/tanstack-router-demo/scripts/lib/page-manifest.mjs new file mode 100644 index 00000000..59b31595 --- /dev/null +++ b/packages/tanstack-router-demo/scripts/lib/page-manifest.mjs @@ -0,0 +1,235 @@ +// 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. +// +// Shared page-boundary codegen, used by BOTH authoring frontends: +// - gen-mpa.mjs (TanStack file convention: `export const page` in a route +// file, or a `-container.ts` boundary file per directory) +// - gen-next.mjs (Next-style app directory, marker: `export const container`) +// Each frontend extracts its own marker; the manifest they compile to is the +// same artifact (schema v1), consumed by the same runtime. +// +// Extraction is TypeScript-AST based (no regex, no eval of app source): the +// marker must be a statically evaluable literal — plain values only. Anything +// dynamic fails the build instead of silently mis-partitioning routes. +import ts from 'typescript'; + +export const MANIFEST_VERSION = 1; +export const DEFAULT_SCHEME_BASE = 'hybrid://lynxview_page'; + +/** Statically evaluate a literal expression; throw on anything dynamic. */ +function literalValue(node, file, sourceFile) { + // `satisfies X` / `as X` wrappers around a literal are fine — unwrap. + if (ts.isSatisfiesExpression?.(node) || ts.isAsExpression(node)) { + return literalValue(node.expression, file, sourceFile); + } + if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text; + if (ts.isNumericLiteral(node)) return Number(node.text); + if (node.kind === ts.SyntaxKind.TrueKeyword) return true; + if (node.kind === ts.SyntaxKind.FalseKeyword) return false; + if (node.kind === ts.SyntaxKind.NullKeyword) return null; + if (ts.isArrayLiteralExpression(node)) { + return node.elements.map((el) => literalValue(el, file, sourceFile)); + } + if (ts.isObjectLiteralExpression(node)) { + const out = {}; + for (const prop of node.properties) { + if (!ts.isPropertyAssignment(prop)) { + throw new Error( + `page-manifest: ${file} — marker object may only contain plain ` + + `\`key: value\` properties (no spreads, methods, or shorthand).`, + ); + } + const name = + ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name) ? prop.name.text : undefined; + if (name === undefined) { + throw new Error(`page-manifest: ${file} — computed property names are not supported.`); + } + out[name] = literalValue(prop.initializer, file, sourceFile); + } + return out; + } + throw new Error( + `page-manifest: ${file} — marker must be a statically evaluable literal; found ` + + `\`${node.getText(sourceFile)}\`. Use plain strings/numbers/booleans/objects/arrays.`, + ); +} + +/** + * Extract `export const = { ... }` from a source file via the TS AST. + * Returns undefined when the export is absent; throws when it exists in a + * form that cannot be statically read (referenced constant, re-export, ...). + */ +export function extractExportedObjectLiteral(src, name, file) { + const sourceFile = ts.createSourceFile( + file, + src, + ts.ScriptTarget.Latest, + true, + file.endsWith('x') ? ts.ScriptKind.TSX : ts.ScriptKind.TS, + ); + for (const statement of sourceFile.statements) { + // `export { page }` / `export { x as page }` — unreadable statically. + if (ts.isExportDeclaration(statement) && statement.exportClause && + ts.isNamedExports(statement.exportClause)) { + for (const el of statement.exportClause.elements) { + if (el.name.text === name) { + throw new Error( + `page-manifest: ${file} re-exports \`${name}\`; declare it inline as ` + + `\`export const ${name} = { ... }\` so the build can read it.`, + ); + } + } + } + if (!ts.isVariableStatement(statement)) continue; + const isExported = statement.modifiers?.some( + (m) => m.kind === ts.SyntaxKind.ExportKeyword, + ); + for (const decl of statement.declarationList.declarations) { + if (!ts.isIdentifier(decl.name) || decl.name.text !== name) continue; + if (!isExported) continue; + if (!decl.initializer) { + throw new Error(`page-manifest: ${file} exports \`${name}\` without an initializer.`); + } + const value = literalValue(decl.initializer, file, sourceFile); + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new Error(`page-manifest: ${file} — \`${name}\` must be an object literal.`); + } + return value; + } + } + return undefined; +} + +/** The static path prefix a page owns (up to the first param segment). */ +export function staticPrefix(routePath) { + const segs = routePath.split('/'); + const out = []; + for (const seg of segs) { + if (seg.startsWith('$') || seg === '') { + if (seg === '' && out.length === 0) out.push(''); // keep leading '/' + if (seg.startsWith('$')) break; + continue; + } + out.push(seg); + } + const prefix = out.join('/') || '/'; + return prefix === '' ? '/' : prefix; +} + +/** Longest-prefix match score; mirrors sparkling-history's resolve-page. */ +function matchLen(pathname, prefix) { + if (prefix === '/') return pathname === '/' ? 1 : 0.5; + if (pathname === prefix) return prefix.length + 1; + if (pathname.startsWith(prefix.endsWith('/') ? prefix : prefix + '/')) { + return prefix.length; + } + return 0; +} + +/** + * Boundary-containment validation (subset rule R3, static approximation). + * An UNMARKED route falls into the root page by default — but if its path + * sits under a prefix another page explicitly claimed with a marker, the + * default assignment contradicts the territory map and the app would render + * the route in a surprising container. Fail the build; the fix is to move + * the file or give it its own boundary marker. Marked routes are always + * legitimate (an explicit carve-out, e.g. a modal page inside another + * page's path space). + */ +export function validateBoundaries(routes, rootPageId) { + // Territory prefixes declared by markers, keyed by owning page. + const declared = []; + for (const r of routes) { + if (r.page) declared.push({ id: r.page.id, prefix: staticPrefix(r.path) }); + } + for (const r of routes) { + if (r.page) continue; // explicit assignment — always fine + const pathname = staticPrefix(r.path); + let best; + let bestLen = 0; + for (const d of declared) { + const len = matchLen(pathname, d.prefix); + if (len > bestLen) { + bestLen = len; + best = d; + } + } + if (best && best.id !== rootPageId) { + throw new Error( + `page-manifest: route '${r.path}' has no boundary marker (so it falls ` + + `into root page '${rootPageId}') but sits under page '${best.id}''s ` + + `path prefix '${best.prefix}' — at runtime it would open in the wrong ` + + `container. Move it, or give it its own boundary marker.`, + ); + } + } +} + +/** + * Compile route records into manifest pages. + * @param {Array<{ path: string, page?: { id: string, root?: boolean, presentation?: string, containerParams?: Record } }>} routes + */ +export function buildPages(routes) { + const rootPage = routes.find((r) => r.page?.root)?.page; + if (!rootPage) { + throw new Error('No root page found: exactly one route must declare a page with `root: true`.'); + } + + const byId = new Map(); + const ensure = (id, marker) => { + if (!byId.has(id)) { + byId.set(id, { id, paths: new Set(), containerParams: marker?.containerParams, presentation: marker?.presentation }); + } else { + const e = byId.get(id); + if (marker?.containerParams && !e.containerParams) e.containerParams = marker.containerParams; + if (marker?.presentation && !e.presentation) e.presentation = marker.presentation; + } + return byId.get(id); + }; + + for (const r of routes) { + const pageId = r.page?.id ?? rootPage.id; // unmarked routes -> root page + const entry = ensure(pageId, r.page); + entry.paths.add(staticPrefix(r.path)); + // The declaring route (the one carrying the page marker) is the page's + // deep-link default. Param segments can't be defaulted; use the static + // prefix of that route instead. + if (r.page) { + const candidate = r.path.includes('$') ? staticPrefix(r.path) : r.path; + if (!entry.defaultHref || candidate.length < entry.defaultHref.length) { + entry.defaultHref = candidate; + } + } + } + + // Deterministic order (by id) so different frontends emit identical + // manifests for the same app. + const pages = [...byId.values()] + .map((p) => ({ + id: p.id, + paths: [...p.paths].sort(), + ...(p.presentation && p.presentation !== 'push' ? { presentation: p.presentation } : {}), + ...(p.containerParams ? { containerParams: p.containerParams } : {}), + ...(p.defaultHref ? { defaultHref: p.defaultHref } : {}), + })) + .sort((a, b) => a.id.localeCompare(b.id)); + + validateBoundaries(routes, rootPage.id); + return pages; +} + +/** Render the manifest TS module source (schema v1). */ +export function renderManifestModule(pages, generatedBy, schemeBase = DEFAULT_SCHEME_BASE) { + const manifest = { + version: MANIFEST_VERSION, + scheme: { base: schemeBase }, + pages, + }; + return ( + `// AUTO-GENERATED by ${generatedBy} — do not edit.\n` + + `// Route -> native-page (bundle) mapping, schema v${MANIFEST_VERSION}.\n` + + `import type { PageManifest } from 'sparkling-history';\n\n` + + `export const manifest: PageManifest = ${JSON.stringify(manifest, null, 2)};\n` + ); +} diff --git a/packages/tanstack-router-demo/src/app/detail/[id]/page.tsx b/packages/tanstack-router-demo/src/app/detail/[id]/page.tsx new file mode 100644 index 00000000..3743d838 --- /dev/null +++ b/packages/tanstack-router-demo/src/app/detail/[id]/page.tsx @@ -0,0 +1,40 @@ +// 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 { useNavigate, useParams, useRouter, useSearch } from '@tanstack/react-router'; +import { Screen, NavButton } from '../../../ui.js'; + +// Native-page boundary: /detail/* starts its own native page (bundle). +export const container = { id: 'detail', containerParams: { title: 'Detail' } }; + +// Extra TanStack route options the translator spreads into the generated +// route (functions can't be compiled into the manifest, so they pass through +// as code, not data). +export const routeOptions = { + validateSearch: (search: Record) => ({ + ref: typeof search.ref === 'string' ? search.ref : undefined, + }), +}; + +export default function DetailPage() { + const navigate = useNavigate(); + const router = useRouter(); + const { id } = useParams({ strict: false }) as { id?: string }; + const search = useSearch({ strict: false }) as { ref?: string }; + return ( + + + {`Path param id=${id}, search ref=${search.ref ?? '?'}.`} + + + Authored as app/detail/[id]/page.tsx; booted in its own JS context. + + navigate({ to: '/detail/$id', params: { id: '43' }, search: { ref: 'detail' } })} + /> + router.history.back()} /> + + ); +} diff --git a/packages/tanstack-router-demo/src/app/layout.tsx b/packages/tanstack-router-demo/src/app/layout.tsx new file mode 100644 index 00000000..3d0f1c45 --- /dev/null +++ b/packages/tanstack-router-demo/src/app/layout.tsx @@ -0,0 +1,11 @@ +// 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. +// +// Next-style root layout: wraps every route. The gen-next translator turns +// this into the generated tree's root route (children render via Outlet). +import type { ReactNode } from 'react'; + +export default function RootLayout({ children }: { children: ReactNode }) { + return {children}; +} diff --git a/packages/tanstack-router-demo/src/app/page.tsx b/packages/tanstack-router-demo/src/app/page.tsx new file mode 100644 index 00000000..f756cdd2 --- /dev/null +++ b/packages/tanstack-router-demo/src/app/page.tsx @@ -0,0 +1,36 @@ +// 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 { useNavigate } from '@tanstack/react-router'; +import { Screen, NavButton } from '../ui.js'; + +// Native-page boundary marker for the Next-style frontend (the analogue of the +// TanStack convention's `export const page`): the root page of the app. +export const container = { id: 'home', root: true }; + +export default function HomePage() { + const navigate = useNavigate(); + return ( + + + Authored via the Next-style app directory; running on the same TanStack + core and sparkling-history runtime as the TanStack-convention app. + + navigate({ to: '/profile' })} + /> + navigate({ to: '/detail/$id', params: { id: '42' }, search: { ref: 'home' } })} + /> + navigate({ to: '/settings' })} + /> + + ); +} diff --git a/packages/tanstack-router-demo/src/app/profile/page.tsx b/packages/tanstack-router-demo/src/app/profile/page.tsx new file mode 100644 index 00000000..0b735043 --- /dev/null +++ b/packages/tanstack-router-demo/src/app/profile/page.tsx @@ -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 { useNavigate } from '@tanstack/react-router'; +import { Screen, NavButton } from '../../ui.js'; + +// No `container` export: this route lives in the root page's bundle (in-page). +export default function ProfilePage() { + const navigate = useNavigate(); + return ( + + + In-page route inside the Home bundle — no native page was opened. + + navigate({ to: '/' })} /> + + ); +} diff --git a/packages/tanstack-router-demo/src/app/settings/page.tsx b/packages/tanstack-router-demo/src/app/settings/page.tsx new file mode 100644 index 00000000..49c2371d --- /dev/null +++ b/packages/tanstack-router-demo/src/app/settings/page.tsx @@ -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 { useRouter } from '@tanstack/react-router'; +import { Screen, NavButton } from '../../ui.js'; + +// Native-page boundary: its own native page, presented modally. +export const container = { + id: 'settings', + presentation: 'modal', + containerParams: { title: 'Settings' }, +}; + +export default function SettingsPage() { + const router = useRouter(); + return ( + + + Another native page. Back returns to whoever opened it. + + router.history.back()} /> + + ); +} diff --git a/packages/tanstack-router-demo/src/mpa/create-router.tsx b/packages/tanstack-router-demo/src/mpa/create-router.tsx index c5ef0339..6ba7e6a3 100644 --- a/packages/tanstack-router-demo/src/mpa/create-router.tsx +++ b/packages/tanstack-router-demo/src/mpa/create-router.tsx @@ -2,6 +2,8 @@ // 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 { createRouter } from '@tanstack/react-router'; +import type { AnyRoute } from '@tanstack/react-router'; +import type { PageManifest } from 'sparkling-history'; import { createMpaHistory, createManifestPageResolver, @@ -9,11 +11,10 @@ import { } from 'sparkling-history'; import { createSparklingHost } from 'sparkling-history/sparkling'; import * as navigation from 'sparkling-navigation'; -// Route tree generated by @tanstack/router-generator (official file-based -// generator); manifest generated by scripts/gen-mpa.mjs (our MPA extension). -// Both derive from src/routes/*. -import { routeTree } from '../routeTree.gen.js'; -import { manifest } from '../routes.manifest.js'; +// The runtime deliberately imports NO route tree and NO manifest: every +// bundle entry passes its own pair (the pruned per-page tree + the shared +// manifest). A static default import here would drag the FULL tree — and +// every page's components — into every bundle, defeating pruning. // Extensionless on purpose: resolves to web-nav/setup.web.ts in the website // (go-web) build via the `.web.ts` extension preference, and to the native // no-op stub (setup.ts) otherwise. See below. @@ -51,10 +52,17 @@ function LynxErrorComponent({ error }: { error: Error }) { * shim. Cross-page navigations become native page opens; in-page navigations * stay within this bundle. */ -export function createMpaRouter(opts?: { pageId?: string }) { +export function createMpaRouter(opts: { + pageId?: string; + routeTree: AnyRoute; + manifest: PageManifest; +}) { + const { routeTree, manifest } = opts; const host = createSparklingHost({ navigation: navigation as never, getQueryItems: readQueryItems, + // Manifest v1 carries the scheme base; fall back to the host default. + ...(manifest.scheme?.base ? { baseScheme: manifest.scheme.base } : {}), // External deep links open this bundle without `__mpa_href`; render this // page's own default route, not the app root. defaultHref: opts?.pageId ? defaultHrefForPage(manifest, opts.pageId) : '/', diff --git a/packages/tanstack-router-demo/src/mpa/mount.tsx b/packages/tanstack-router-demo/src/mpa/mount.tsx index 0f232241..726e36ab 100644 --- a/packages/tanstack-router-demo/src/mpa/mount.tsx +++ b/packages/tanstack-router-demo/src/mpa/mount.tsx @@ -11,9 +11,10 @@ import { createMpaRouter } from './create-router.js'; * location from the launch queryItems (`__mpa_href`), so the same code renders * Home in the home bundle and Detail in the detail bundle. `pageId` names the * page this bundle serves — external deep links (no `__mpa_href`) fall back to - * that page's default route instead of the app root. + * that page's default route instead of the app root. A non-default authoring + * frontend passes its own `routeTree`/`manifest` pair; the boot is identical. */ -export function mount(opts?: { pageId?: string }) { +export function mount(opts: Parameters[0]) { const router = createMpaRouter(opts); root.render(); } diff --git a/packages/tanstack-router-demo/src/page-entries-next.gen.ts b/packages/tanstack-router-demo/src/page-entries-next.gen.ts new file mode 100644 index 00000000..2bb1026b --- /dev/null +++ b/packages/tanstack-router-demo/src/page-entries-next.gen.ts @@ -0,0 +1,6 @@ +// AUTO-GENERATED by scripts/gen-next.mjs — do not edit. +export const nextPageEntries = { + "detail": "./src/pages-next.gen/detail/index.tsx", + "home": "./src/pages-next.gen/home/index.tsx", + "settings": "./src/pages-next.gen/settings/index.tsx" +}; diff --git a/packages/tanstack-router-demo/src/pages-next.gen/detail/index.tsx b/packages/tanstack-router-demo/src/pages-next.gen/detail/index.tsx new file mode 100644 index 00000000..a6c5edf8 --- /dev/null +++ b/packages/tanstack-router-demo/src/pages-next.gen/detail/index.tsx @@ -0,0 +1,7 @@ +// AUTO-GENERATED by scripts/gen-next.mjs — do not edit. +// Bundle entry for the 'detail' native page, next-style frontend. +import { mount } from '../../mpa/mount.js'; +import { routeTree } from '../../routeTree.next.gen.js'; +import { manifest } from '../../routes-next.manifest.js'; + +mount({ pageId: 'detail', routeTree, manifest }); diff --git a/packages/tanstack-router-demo/src/pages-next.gen/home/index.tsx b/packages/tanstack-router-demo/src/pages-next.gen/home/index.tsx new file mode 100644 index 00000000..40eae96a --- /dev/null +++ b/packages/tanstack-router-demo/src/pages-next.gen/home/index.tsx @@ -0,0 +1,7 @@ +// AUTO-GENERATED by scripts/gen-next.mjs — do not edit. +// Bundle entry for the 'home' native page, next-style frontend. +import { mount } from '../../mpa/mount.js'; +import { routeTree } from '../../routeTree.next.gen.js'; +import { manifest } from '../../routes-next.manifest.js'; + +mount({ pageId: 'home', routeTree, manifest }); diff --git a/packages/tanstack-router-demo/src/pages-next.gen/settings/index.tsx b/packages/tanstack-router-demo/src/pages-next.gen/settings/index.tsx new file mode 100644 index 00000000..b68d28d9 --- /dev/null +++ b/packages/tanstack-router-demo/src/pages-next.gen/settings/index.tsx @@ -0,0 +1,7 @@ +// AUTO-GENERATED by scripts/gen-next.mjs — do not edit. +// Bundle entry for the 'settings' native page, next-style frontend. +import { mount } from '../../mpa/mount.js'; +import { routeTree } from '../../routeTree.next.gen.js'; +import { manifest } from '../../routes-next.manifest.js'; + +mount({ pageId: 'settings', routeTree, manifest }); diff --git a/packages/tanstack-router-demo/src/pages.gen/detail/index.tsx b/packages/tanstack-router-demo/src/pages.gen/detail/index.tsx index 3f43f766..9347a0f8 100644 --- a/packages/tanstack-router-demo/src/pages.gen/detail/index.tsx +++ b/packages/tanstack-router-demo/src/pages.gen/detail/index.tsx @@ -1,8 +1,9 @@ // AUTO-GENERATED by scripts/gen-mpa.mjs — do not edit. -// Bundle entry for the 'detail' native page. Every page boots the same -// router (from the generated route tree); its start location comes from -// the launch queryItems, falling back to this page's default route when -// opened by an external deep link. +// Bundle entry for the 'detail' native page. Boots the shared runtime +// with this page's pruned tree; start location comes from the launch +// queryItems, falling back to this page's default route on deep link. import { mount } from '../../mpa/mount.js'; +import { routeTree } from './routeTree.js'; +import { manifest } from '../../routes.manifest.js'; -mount({ pageId: 'detail' }); +mount({ pageId: 'detail', routeTree, manifest }); diff --git a/packages/tanstack-router-demo/src/pages.gen/detail/routeTree.ts b/packages/tanstack-router-demo/src/pages.gen/detail/routeTree.ts new file mode 100644 index 00000000..da3cf470 --- /dev/null +++ b/packages/tanstack-router-demo/src/pages.gen/detail/routeTree.ts @@ -0,0 +1,27 @@ +// AUTO-GENERATED by scripts/gen-mpa.mjs — do not edit. +// Pruned route tree for the 'detail' page: only this page's routes +// carry components/options; every other route is a path-only stub for +// href building. The full tree (routeTree.gen.ts) stays the type-level +// source of truth. +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { createRootRoute, createRoute } from '@tanstack/react-router'; +import { Route as rootRouteImport } from '../../routes/__root.js'; +import { Route as R_detail__id } from '../../routes/detail.$id.js'; + +// Fresh root cloned from the app root's options: pruned trees must not +// mutate shared file-route singletons (the full tree links them too). +const root = createRootRoute((rootRouteImport as any).options); + +const R_detail__id_route = createRoute({ + ...(R_detail__id as any).options, + path: '/detail/$id', + getParentRoute: () => root, +}); +// '/' lives in page 'home' — path-only stub. +const stub_0 = createRoute({ path: '/', getParentRoute: () => root }); +// '/profile' lives in page 'home' — path-only stub. +const stub_1 = createRoute({ path: '/profile', getParentRoute: () => root }); +// '/settings' lives in page 'settings' — path-only stub. +const stub_2 = createRoute({ path: '/settings', getParentRoute: () => root }); + +export const routeTree = root.addChildren([R_detail__id_route, stub_0, stub_1, stub_2]); diff --git a/packages/tanstack-router-demo/src/pages.gen/home/index.tsx b/packages/tanstack-router-demo/src/pages.gen/home/index.tsx index 0dc9b328..cb050bb8 100644 --- a/packages/tanstack-router-demo/src/pages.gen/home/index.tsx +++ b/packages/tanstack-router-demo/src/pages.gen/home/index.tsx @@ -1,8 +1,9 @@ // AUTO-GENERATED by scripts/gen-mpa.mjs — do not edit. -// Bundle entry for the 'home' native page. Every page boots the same -// router (from the generated route tree); its start location comes from -// the launch queryItems, falling back to this page's default route when -// opened by an external deep link. +// Bundle entry for the 'home' native page. Boots the shared runtime +// with this page's pruned tree; start location comes from the launch +// queryItems, falling back to this page's default route on deep link. import { mount } from '../../mpa/mount.js'; +import { routeTree } from './routeTree.js'; +import { manifest } from '../../routes.manifest.js'; -mount({ pageId: 'home' }); +mount({ pageId: 'home', routeTree, manifest }); diff --git a/packages/tanstack-router-demo/src/pages.gen/home/routeTree.ts b/packages/tanstack-router-demo/src/pages.gen/home/routeTree.ts new file mode 100644 index 00000000..e1a35eb2 --- /dev/null +++ b/packages/tanstack-router-demo/src/pages.gen/home/routeTree.ts @@ -0,0 +1,31 @@ +// AUTO-GENERATED by scripts/gen-mpa.mjs — do not edit. +// Pruned route tree for the 'home' page: only this page's routes +// carry components/options; every other route is a path-only stub for +// href building. The full tree (routeTree.gen.ts) stays the type-level +// source of truth. +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { createRootRoute, createRoute } from '@tanstack/react-router'; +import { Route as rootRouteImport } from '../../routes/__root.js'; +import { Route as R_index } from '../../routes/index.js'; +import { Route as R_profile } from '../../routes/profile.js'; + +// Fresh root cloned from the app root's options: pruned trees must not +// mutate shared file-route singletons (the full tree links them too). +const root = createRootRoute((rootRouteImport as any).options); + +const R_index_route = createRoute({ + ...(R_index as any).options, + path: '/', + getParentRoute: () => root, +}); +const R_profile_route = createRoute({ + ...(R_profile as any).options, + path: '/profile', + getParentRoute: () => root, +}); +// '/detail/$id' lives in page 'detail' — path-only stub. +const stub_0 = createRoute({ path: '/detail/$id', getParentRoute: () => root }); +// '/settings' lives in page 'settings' — path-only stub. +const stub_1 = createRoute({ path: '/settings', getParentRoute: () => root }); + +export const routeTree = root.addChildren([R_index_route, R_profile_route, stub_0, stub_1]); diff --git a/packages/tanstack-router-demo/src/pages.gen/settings/index.tsx b/packages/tanstack-router-demo/src/pages.gen/settings/index.tsx index 3cae98fe..94685fc2 100644 --- a/packages/tanstack-router-demo/src/pages.gen/settings/index.tsx +++ b/packages/tanstack-router-demo/src/pages.gen/settings/index.tsx @@ -1,8 +1,9 @@ // AUTO-GENERATED by scripts/gen-mpa.mjs — do not edit. -// Bundle entry for the 'settings' native page. Every page boots the same -// router (from the generated route tree); its start location comes from -// the launch queryItems, falling back to this page's default route when -// opened by an external deep link. +// Bundle entry for the 'settings' native page. Boots the shared runtime +// with this page's pruned tree; start location comes from the launch +// queryItems, falling back to this page's default route on deep link. import { mount } from '../../mpa/mount.js'; +import { routeTree } from './routeTree.js'; +import { manifest } from '../../routes.manifest.js'; -mount({ pageId: 'settings' }); +mount({ pageId: 'settings', routeTree, manifest }); diff --git a/packages/tanstack-router-demo/src/pages.gen/settings/routeTree.ts b/packages/tanstack-router-demo/src/pages.gen/settings/routeTree.ts new file mode 100644 index 00000000..6ebc3f69 --- /dev/null +++ b/packages/tanstack-router-demo/src/pages.gen/settings/routeTree.ts @@ -0,0 +1,27 @@ +// AUTO-GENERATED by scripts/gen-mpa.mjs — do not edit. +// Pruned route tree for the 'settings' page: only this page's routes +// carry components/options; every other route is a path-only stub for +// href building. The full tree (routeTree.gen.ts) stays the type-level +// source of truth. +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { createRootRoute, createRoute } from '@tanstack/react-router'; +import { Route as rootRouteImport } from '../../routes/__root.js'; +import { Route as R_settings } from '../../routes/settings.js'; + +// Fresh root cloned from the app root's options: pruned trees must not +// mutate shared file-route singletons (the full tree links them too). +const root = createRootRoute((rootRouteImport as any).options); + +const R_settings_route = createRoute({ + ...(R_settings as any).options, + path: '/settings', + getParentRoute: () => root, +}); +// '/' lives in page 'home' — path-only stub. +const stub_0 = createRoute({ path: '/', getParentRoute: () => root }); +// '/detail/$id' lives in page 'detail' — path-only stub. +const stub_1 = createRoute({ path: '/detail/$id', getParentRoute: () => root }); +// '/profile' lives in page 'home' — path-only stub. +const stub_2 = createRoute({ path: '/profile', getParentRoute: () => root }); + +export const routeTree = root.addChildren([R_settings_route, stub_0, stub_1, stub_2]); diff --git a/packages/tanstack-router-demo/src/routeTree.next.gen.ts b/packages/tanstack-router-demo/src/routeTree.next.gen.ts new file mode 100644 index 00000000..e24bebf1 --- /dev/null +++ b/packages/tanstack-router-demo/src/routeTree.next.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-next.gen/__root'; +import { Route as SettingsRouteImport } from './routes-next.gen/settings'; +import { Route as ProfileRouteImport } from './routes-next.gen/profile'; +import { Route as IndexRouteImport } from './routes-next.gen/index'; +import { Route as DetailIdRouteImport } from './routes-next.gen/detail.$id'; + +const SettingsRoute = SettingsRouteImport.update({ + id: '/settings', + path: '/settings', + getParentRoute: () => rootRouteImport, +} as any); +const ProfileRoute = ProfileRouteImport.update({ + id: '/profile', + path: '/profile', + getParentRoute: () => rootRouteImport, +} as any); +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any); +const DetailIdRoute = DetailIdRouteImport.update({ + id: '/detail/$id', + path: '/detail/$id', + getParentRoute: () => rootRouteImport, +} as any); + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute; + '/profile': typeof ProfileRoute; + '/settings': typeof SettingsRoute; + '/detail/$id': typeof DetailIdRoute; +} +export interface FileRoutesByTo { + '/': typeof IndexRoute; + '/profile': typeof ProfileRoute; + '/settings': typeof SettingsRoute; + '/detail/$id': typeof DetailIdRoute; +} +export interface FileRoutesById { + __root__: typeof rootRouteImport; + '/': typeof IndexRoute; + '/profile': typeof ProfileRoute; + '/settings': typeof SettingsRoute; + '/detail/$id': typeof DetailIdRoute; +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath; + fullPaths: '/' | '/profile' | '/settings' | '/detail/$id'; + fileRoutesByTo: FileRoutesByTo; + to: '/' | '/profile' | '/settings' | '/detail/$id'; + id: '__root__' | '/' | '/profile' | '/settings' | '/detail/$id'; + fileRoutesById: FileRoutesById; +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute; + ProfileRoute: typeof ProfileRoute; + SettingsRoute: typeof SettingsRoute; + DetailIdRoute: typeof DetailIdRoute; +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/settings': { + id: '/settings'; + path: '/settings'; + fullPath: '/settings'; + preLoaderRoute: typeof SettingsRouteImport; + parentRoute: typeof rootRouteImport; + }; + '/profile': { + id: '/profile'; + path: '/profile'; + fullPath: '/profile'; + preLoaderRoute: typeof ProfileRouteImport; + parentRoute: typeof rootRouteImport; + }; + '/': { + id: '/'; + path: '/'; + fullPath: '/'; + preLoaderRoute: typeof IndexRouteImport; + parentRoute: typeof rootRouteImport; + }; + '/detail/$id': { + id: '/detail/$id'; + path: '/detail/$id'; + fullPath: '/detail/$id'; + preLoaderRoute: typeof DetailIdRouteImport; + parentRoute: typeof rootRouteImport; + }; + } +} + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + ProfileRoute: ProfileRoute, + SettingsRoute: SettingsRoute, + DetailIdRoute: DetailIdRoute, +}; +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes(); diff --git a/packages/tanstack-router-demo/src/routes-next.gen/__root.tsx b/packages/tanstack-router-demo/src/routes-next.gen/__root.tsx new file mode 100644 index 00000000..617f81e6 --- /dev/null +++ b/packages/tanstack-router-demo/src/routes-next.gen/__root.tsx @@ -0,0 +1,11 @@ +// AUTO-GENERATED by scripts/gen-next.mjs — do not edit. +import { createRootRoute, Outlet } from '@tanstack/react-router'; +import RootLayout from '../app/layout.js'; + +export const Route = createRootRoute({ + component: () => ( + + + + ), +}); diff --git a/packages/tanstack-router-demo/src/routes-next.gen/detail.$id.tsx b/packages/tanstack-router-demo/src/routes-next.gen/detail.$id.tsx new file mode 100644 index 00000000..573d94e5 --- /dev/null +++ b/packages/tanstack-router-demo/src/routes-next.gen/detail.$id.tsx @@ -0,0 +1,11 @@ +// AUTO-GENERATED by scripts/gen-next.mjs — do not edit. +import { createFileRoute } from '@tanstack/react-router'; +import Page, { routeOptions } from '../app/detail/[id]/page.js'; + +// Page boundary translated from `export const container` in the app file. +export const page = {"id":"detail","containerParams":{"title":"Detail"}}; + +export const Route = createFileRoute('/detail/$id')({ + ...routeOptions, + component: Page, +}); diff --git a/packages/tanstack-router-demo/src/routes-next.gen/index.tsx b/packages/tanstack-router-demo/src/routes-next.gen/index.tsx new file mode 100644 index 00000000..b1b6db74 --- /dev/null +++ b/packages/tanstack-router-demo/src/routes-next.gen/index.tsx @@ -0,0 +1,10 @@ +// AUTO-GENERATED by scripts/gen-next.mjs — do not edit. +import { createFileRoute } from '@tanstack/react-router'; +import Page from '../app/page.js'; + +// Page boundary translated from `export const container` in the app file. +export const page = {"id":"home","root":true}; + +export const Route = createFileRoute('/')({ + component: Page, +}); diff --git a/packages/tanstack-router-demo/src/routes-next.gen/profile.tsx b/packages/tanstack-router-demo/src/routes-next.gen/profile.tsx new file mode 100644 index 00000000..a377f6dd --- /dev/null +++ b/packages/tanstack-router-demo/src/routes-next.gen/profile.tsx @@ -0,0 +1,7 @@ +// AUTO-GENERATED by scripts/gen-next.mjs — do not edit. +import { createFileRoute } from '@tanstack/react-router'; +import Page from '../app/profile/page.js'; + +export const Route = createFileRoute('/profile')({ + component: Page, +}); diff --git a/packages/tanstack-router-demo/src/routes-next.gen/settings.tsx b/packages/tanstack-router-demo/src/routes-next.gen/settings.tsx new file mode 100644 index 00000000..4e0b6fee --- /dev/null +++ b/packages/tanstack-router-demo/src/routes-next.gen/settings.tsx @@ -0,0 +1,10 @@ +// AUTO-GENERATED by scripts/gen-next.mjs — do not edit. +import { createFileRoute } from '@tanstack/react-router'; +import Page from '../app/settings/page.js'; + +// Page boundary translated from `export const container` in the app file. +export const page = {"id":"settings","presentation":"modal","containerParams":{"title":"Settings"}}; + +export const Route = createFileRoute('/settings')({ + component: Page, +}); diff --git a/packages/tanstack-router-demo/src/routes-next.manifest.ts b/packages/tanstack-router-demo/src/routes-next.manifest.ts new file mode 100644 index 00000000..3875b974 --- /dev/null +++ b/packages/tanstack-router-demo/src/routes-next.manifest.ts @@ -0,0 +1,41 @@ +// AUTO-GENERATED by scripts/gen-next.mjs — do not edit. +// Route -> native-page (bundle) mapping, schema v1. +import type { PageManifest } from 'sparkling-history'; + +export const manifest: PageManifest = { + "version": 1, + "scheme": { + "base": "hybrid://lynxview_page" + }, + "pages": [ + { + "id": "detail", + "paths": [ + "/detail" + ], + "containerParams": { + "title": "Detail" + }, + "defaultHref": "/detail" + }, + { + "id": "home", + "paths": [ + "/", + "/profile" + ], + "defaultHref": "/" + }, + { + "id": "settings", + "paths": [ + "/settings" + ], + "presentation": "modal", + "containerParams": { + "title": "Settings" + }, + "defaultHref": "/settings" + } + ] +}; diff --git a/packages/tanstack-router-demo/src/routes.manifest.ts b/packages/tanstack-router-demo/src/routes.manifest.ts index bd34eb6b..6c0cb404 100644 --- a/packages/tanstack-router-demo/src/routes.manifest.ts +++ b/packages/tanstack-router-demo/src/routes.manifest.ts @@ -1,8 +1,12 @@ // AUTO-GENERATED by scripts/gen-mpa.mjs — do not edit. -// Route -> native-page (bundle) mapping, derived from src/routes/*. +// Route -> native-page (bundle) mapping, schema v1. import type { PageManifest } from 'sparkling-history'; export const manifest: PageManifest = { + "version": 1, + "scheme": { + "base": "hybrid://lynxview_page" + }, "pages": [ { "id": "detail", @@ -27,6 +31,7 @@ export const manifest: PageManifest = { "paths": [ "/settings" ], + "presentation": "modal", "containerParams": { "title": "Settings" }, diff --git a/packages/tanstack-router-demo/src/routes/settings.tsx b/packages/tanstack-router-demo/src/routes/settings.tsx index e3dc7c9f..f1eeb03d 100644 --- a/packages/tanstack-router-demo/src/routes/settings.tsx +++ b/packages/tanstack-router-demo/src/routes/settings.tsx @@ -4,8 +4,13 @@ import { createFileRoute, useRouter } from '@tanstack/react-router'; import { Screen, NavButton } from '../ui.js'; -// MPA extension: its own native page. -export const page = { id: 'settings', containerParams: { title: 'Settings' } }; +// MPA extension: its own native page, presented modally (manifest v1 +// `presentation` — native support pending, containers fall back to push). +export const page = { + id: 'settings', + presentation: 'modal', + containerParams: { title: 'Settings' }, +}; export const Route = createFileRoute('/settings')({ component: SettingsPage, diff --git a/packages/tanstack-router-demo/tests/codegen-boundaries.test.ts b/packages/tanstack-router-demo/tests/codegen-boundaries.test.ts new file mode 100644 index 00000000..278657fe --- /dev/null +++ b/packages/tanstack-router-demo/tests/codegen-boundaries.test.ts @@ -0,0 +1,90 @@ +// 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. +// +// Codegen boundary semantics: AST marker extraction, `-container` directory +// boundaries, and the R3 containment check (a route nested under another +// page's prefix without its own marker fails the build). +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { afterAll, describe, expect, test } from 'vitest'; +import { collectTanstackRoutes } from '../scripts/lib/collect-routes.mjs'; +import { + buildPages, + extractExportedObjectLiteral, +} from '../scripts/lib/page-manifest.mjs'; + +const roots: string[] = []; +function fixture(files: Record) { + const root = mkdtempSync(join(tmpdir(), 'sparkling-routes-')); + roots.push(root); + for (const [file, src] of Object.entries(files)) { + mkdirSync(dirname(join(root, file)), { recursive: true }); + writeFileSync(join(root, file), src); + } + return root; +} +afterAll(() => roots.forEach((r) => rmSync(r, { recursive: true, force: true }))); + +describe('AST marker extraction', () => { + test('reads object literals, satisfies-wrapped and nested', () => { + const v = extractExportedObjectLiteral( + `export const page = { id: 'x', presentation: 'modal', containerParams: { title: 'T' } } satisfies Record;`, + 'page', + 'x.tsx', + ); + expect(v).toEqual({ id: 'x', presentation: 'modal', containerParams: { title: 'T' } }); + }); + + test('fails loud on a re-exported marker', () => { + expect(() => + extractExportedObjectLiteral(`const page = { id: 'x' };\nexport { page };`, 'page', 'x.tsx'), + ).toThrow(/re-exports/); + }); + + test('fails loud on a dynamic marker', () => { + expect(() => + extractExportedObjectLiteral(`export const page = { id: getId() };`, 'page', 'x.tsx'), + ).toThrow(/statically evaluable/); + }); +}); + +describe('-container directory boundaries', () => { + test('routes inherit the nearest -container marker; in-file page wins', () => { + const root = fixture({ + 'index.tsx': `export const page = { id: 'home', root: true };\nexport const Route = 0 as never; // createFileRoute('/')`, + 'feed/-container.ts': `export const container = { id: 'feed', containerParams: { title: 'Feed' } };`, + 'feed/index.tsx': `import { createFileRoute } from '@tanstack/react-router';\nexport const Route = createFileRoute('/feed')({});`, + 'feed/$postId.tsx': `import { createFileRoute } from '@tanstack/react-router';\nexport const Route = createFileRoute('/feed/$postId')({});`, + 'feed/compose.tsx': `import { createFileRoute } from '@tanstack/react-router';\nexport const page = { id: 'compose', presentation: 'modal' };\nexport const Route = createFileRoute('/feed/compose')({});`, + }); + const routes = collectTanstackRoutes(root); + const byPath = Object.fromEntries(routes.map((r) => [r.path, r.page?.id])); + expect(byPath['/feed']).toBe('feed'); + expect(byPath['/feed/$postId']).toBe('feed'); + expect(byPath['/feed/compose']).toBe('compose'); // in-file override + const pages = buildPages(routes); + const feed = pages.find((p) => p.id === 'feed')!; + expect(feed.containerParams).toEqual({ title: 'Feed' }); + expect(feed.defaultHref).toBe('/feed'); + }); + + test('layout route files are rejected (pruning scope guard)', () => { + const root = fixture({ + '_layout.tsx': `export const Route = 0 as never;`, + }); + expect(() => collectTanstackRoutes(root)).toThrow(/layout route/); + }); +}); + +describe('boundary containment (R3 static check)', () => { + test('a route under another page prefix without its own marker fails the build', () => { + const root = fixture({ + 'index.tsx': `export const page = { id: 'home', root: true };\nimport { createFileRoute } from '@tanstack/react-router';\nexport const Route = createFileRoute('/')({});`, + 'detail.$id.tsx': `export const page = { id: 'detail' };\nimport { createFileRoute } from '@tanstack/react-router';\nexport const Route = createFileRoute('/detail/$id')({});`, + 'detail.reviews.tsx': `import { createFileRoute } from '@tanstack/react-router';\nexport const Route = createFileRoute('/detail/reviews')({});`, + }); + expect(() => buildPages(collectTanstackRoutes(root))).toThrow(/wrong container/); + }); +}); diff --git a/packages/tanstack-router-demo/tests/next-parity.test.ts b/packages/tanstack-router-demo/tests/next-parity.test.ts new file mode 100644 index 00000000..b9ac5de4 --- /dev/null +++ b/packages/tanstack-router-demo/tests/next-parity.test.ts @@ -0,0 +1,78 @@ +// 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. +// +// RFC phase 3: two authoring frontends, one core. The Next-style app directory +// (src/app) is translated by scripts/gen-next.mjs into the SAME artifact pair +// (route tree + page manifest) as the TanStack file convention (src/routes), +// and the same runtime consumes either. These tests pin that equivalence. +import { describe, expect, test } from 'vitest'; +import { createRouter } from '@tanstack/react-router'; +import { createMpaHistory, createMemoryHost, createManifestPageResolver } from 'sparkling-history'; +import { routeTree as tanstackTree } from '../src/routeTree.gen.js'; +import { manifest as tanstackManifest } from '../src/routes.manifest.js'; +import { routeTree as nextTree } from '../src/routeTree.next.gen.js'; +import { manifest as nextManifest } from '../src/routes-next.manifest.js'; + +const ORIGIN = 'http://sparkling.local'; + +function makeRouter(routeTree: unknown, initialHref: string, host = createMemoryHost({ initialHref })) { + const history = createMpaHistory({ + host, + resolvePage: createManifestPageResolver(nextManifest), + }); + const router = createRouter({ + routeTree: routeTree as never, + history: history as never, + isServer: false, + origin: ORIGIN, + }); + return { router, host }; +} + +describe('next-style frontend compiles to the same artifacts', () => { + test('route id sets are identical across frontends', () => { + const { router: a } = makeRouter(tanstackTree, '/'); + const { router: b } = makeRouter(nextTree, '/'); + const ids = (r: { routesById: Record }) => Object.keys(r.routesById).sort(); + expect(ids(b as never)).toEqual(ids(a as never)); + }); + + test('page manifests are deep-equal (ids, paths, containerParams, defaultHref)', () => { + expect(nextManifest).toEqual(tanstackManifest); + }); +}); + +describe('the shared runtime consumes the next-frontend artifacts unchanged', () => { + test('boots at the index route', async () => { + const { router } = makeRouter(nextTree, '/'); + await router.load(); + expect(router.state.location.pathname).toBe('/'); + }); + + test('path param + routeOptions (validateSearch) pass through the bridge', async () => { + const { router } = makeRouter(nextTree, '/detail/7?ref=x'); + await router.load(); + const leaf = router.state.matches[router.state.matches.length - 1]!; + expect(leaf.routeId).toBe('/detail/$id'); + expect((leaf.params as { id?: string }).id).toBe('7'); + expect((leaf.search as { ref?: string }).ref).toBe('x'); + }); + + test('cross-page navigation dispatches a native open to the right bundle', async () => { + const { router, host } = makeRouter(nextTree, '/'); + await router.load(); + await router.navigate({ to: '/detail/$id', params: { id: '9' } }); + expect(host.opens).toHaveLength(1); + expect(host.opens[0]!.page.id).toBe('detail'); + }); + + test('/profile stays in-page in the home bundle', async () => { + const { router, host } = makeRouter(nextTree, '/'); + await router.load(); + await router.navigate({ to: '/profile' }); + await router.invalidate(); + expect(host.opens).toHaveLength(0); + expect(router.state.location.pathname).toBe('/profile'); + }); +}); diff --git a/packages/tanstack-router-demo/tests/pruned-trees.test.ts b/packages/tanstack-router-demo/tests/pruned-trees.test.ts new file mode 100644 index 00000000..82f4f4ea --- /dev/null +++ b/packages/tanstack-router-demo/tests/pruned-trees.test.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. +// +// Per-page pruned route trees: each bundle carries only its own page's route +// options; foreign routes are path-only stubs that exist solely so +// `navigate({ to, params })` can build an href for the manifest resolver to +// dispatch. These tests boot each pruned tree the way its bundle entry does. +import { describe, expect, test } from 'vitest'; +import { createRouter } from '@tanstack/react-router'; +import { createMpaHistory, createMemoryHost, createManifestPageResolver } from 'sparkling-history'; +import { manifest } from '../src/routes.manifest.js'; +import { routeTree as homeTree } from '../src/pages.gen/home/routeTree.js'; +import { routeTree as detailTree } from '../src/pages.gen/detail/routeTree.js'; +import { routeTree as settingsTree } from '../src/pages.gen/settings/routeTree.js'; + +const ORIGIN = 'http://sparkling.local'; + +function boot(tree: unknown, initialHref: string) { + const host = createMemoryHost({ initialHref }); + const history = createMpaHistory({ host, resolvePage: createManifestPageResolver(manifest) }); + const router = createRouter({ + routeTree: tree as never, + history: history as never, + isServer: false, + origin: ORIGIN, + }); + return { router, host }; +} + +describe('pruned per-page route trees', () => { + test('manifest is schema v1 with a scheme base', () => { + expect(manifest.version).toBe(1); + expect(manifest.scheme?.base).toBe('hybrid://lynxview_page'); + }); + + test('home tree renders its own routes with full options', async () => { + const { router } = boot(homeTree, '/profile'); + await router.load(); + const leaf = router.state.matches[router.state.matches.length - 1]!; + expect(leaf.routeId).toBe('/profile'); + // Real route, not a stub: the component came through the options spread. + const route = (router.routesById as Record)[ + '/profile' + ]!; + expect(route.options.component).toBeTruthy(); + }); + + test('cross-page navigate from a pruned tree builds the href via the stub and opens the right page', async () => { + const { router, host } = boot(homeTree, '/'); + await router.load(); + await router.navigate({ to: '/detail/$id', params: { id: '9' }, search: { ref: 'x' } } as never); + expect(host.opens).toHaveLength(1); + expect(host.opens[0]!.page.id).toBe('detail'); + expect(host.opens[0]!.href).toContain('/detail/9'); + // The stub never rendered: current location did not move. + expect(router.state.location.pathname).toBe('/'); + }); + + test('presentation flows from the manifest into the open target', async () => { + const { router, host } = boot(homeTree, '/'); + await router.load(); + await router.navigate({ to: '/settings' }); + expect(host.opens).toHaveLength(1); + expect(host.opens[0]!.page.id).toBe('settings'); + expect(host.opens[0]!.page.presentation).toBe('modal'); + }); + + test('detail tree resolves its param route with validateSearch intact', async () => { + const { router } = boot(detailTree, '/detail/7?ref=x'); + await router.load(); + const leaf = router.state.matches[router.state.matches.length - 1]!; + expect(leaf.routeId).toBe('/detail/$id'); + expect((leaf.params as { id?: string }).id).toBe('7'); + expect((leaf.search as { ref?: string }).ref).toBe('x'); + }); + + test('settings tree boots standalone at its default route', async () => { + const { router } = boot(settingsTree, '/settings'); + await router.load(); + const leaf = router.state.matches[router.state.matches.length - 1]!; + expect(leaf.routeId).toBe('/settings'); + }); + + test('stubs carry no component payload', () => { + const { router } = boot(homeTree, '/'); + const stub = (router.routesById as Record)[ + '/settings' + ]!; + expect(stub.options.component).toBeUndefined(); + }); +});