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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions examples/router-demo/lynx.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@ export default defineConfig({
path.dirname(fileURLToPath(import.meta.url)),
'src/react-lynx-shim.ts',
),
'react-dom$': path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'src/shims/react-dom.ts',
),
// Critical for TanStack UI re-renders on ReactLynx / Lynx Web.
'use-sync-external-store/shim/with-selector$':
'@lynx-js/use-sync-external-store/shim/with-selector',
'use-sync-external-store/shim$': '@lynx-js/use-sync-external-store/shim',
},
},
output: {
Expand Down
1 change: 1 addition & 0 deletions examples/router-demo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"@lynx-js/react-rsbuild-plugin": "^0.12.7",
"@lynx-js/rspeedy": "^0.13.3",
"@lynx-js/types": "^3.7.0",
"@lynx-js/use-sync-external-store": "^1.5.0",
"@rsbuild/core": "1.7.2",
"@tanstack/router-plugin": "1.168.23",
"@types/react": "^18.3.20",
Expand Down
7 changes: 7 additions & 0 deletions examples/router-demo/src/shims/react-dom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// Copyright (c) 2026 TikTok Pte. Ltd.
// Licensed under the Apache License Version 2.0 that can be found in the
// LICENSE file in the root directory of this source tree.
export function flushSync<T>(fn: () => T): T {
return fn()
}
export default { flushSync }
6 changes: 6 additions & 0 deletions examples/tanstack-lynx-web-compat/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
dist/
harness-dist/
artifacts/
test-results/
playwright-report/
node_modules/
24 changes: 24 additions & 0 deletions examples/tanstack-lynx-web-compat/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# tanstack-lynx-web-compat

PR2 verification harness: run a **sufficiently large** TanStack Router upstream-derived suite against Sparkling's soft-nav stack on **experimental Lynx for Web**.

## Why not dump upstream vitest as-is?

TanStack's `packages/react-router/tests/*.test.tsx` (~250+ cases) use `@testing-library/react` + real DOM (`button`, `heading`, `IntersectionObserver`, `createBrowserHistory`). ReactLynx / Lynx Web do not provide that DOM surface. This package therefore:

1. **Ports** high-signal upstream cases to headless `router.navigate` / history assertions (vitest)
2. **Replays** soft-nav UX in a real `<lynx-view>` via Playwright
3. **Records** known gaps (`<Link>`/`<a>`, browser history, SSR)

Lynx Web host patterns come from mainline `@lynx-js/web-core` (already on `main` via the website) plus shell ideas from [PR #3](https://github.com/Huxpro/sparkling/pull/3) / `stack/3-sparkling-web-shell` (not merged; we only need a minimal host here).

## Commands

```bash
pnpm install
pnpm test:unit # headless upstream ports
pnpm build # suite.web.bundle + harness
pnpm exec playwright install chromium
pnpm test:e2e # lynx-view soft-nav
pnpm report # refresh RESULTS.md from artifacts
```
49 changes: 49 additions & 0 deletions examples/tanstack-lynx-web-compat/RESULTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# TanStack Router × Lynx-for-Web Compat Results

Generated: 2026-07-26 (local verification)

## Scoreboard

| Layer | Result |
| --- | --- |
| Unit (headless upstream ports, happy-dom) | **26 / 26 passed** |
| E2E (Playwright × `<lynx-view>`) | **6 / 6 passed** |
| Full upstream RTL suite (~250+ DOM tests) | **Not runnable as-is** |

## Environment

- **Lynx for Web on main**: yes for gallery (`@lynx-js/web-core` / `@lynx-js/go-web` on website). **No** `sparkling-web-shell` on main — that lives on [PR #3](https://github.com/Huxpro/sparkling/pull/3) / `stack/3-sparkling-web-shell`. This suite uses a minimal single-view harness instead.
- **Router**: `@tanstack/react-router@1.170.18` + `sparkling-router` `CompositeHistory`
- **Required aliases**: `react$` shim, `react-dom$`→`flushSync`, **`use-sync-external-store/shim*` → `@lynx-js/use-sync-external-store`** (critical)

## What works

- `@tanstack/history` memory (`back` / `forward` / `push` / `replace` / `canGoBack`)
- `router.navigate` path / params / search / replace / relative `..`
- Unicode path params, loaders, `redirect()`, `notFound()`
- `CompositeHistory` soft nav + hard boundary push + soft-back→hard-pop
- Lynx Web boot of TanStack `RouterProvider`
- UI re-renders after navigate **when uSES is aliased**
- Playwright click → `bindtap` (after uSES alias)
- `globalProps.queryItems` into lynx-view
- `url-search-params-polyfill` + `isServer: false`

## What does not work / gaps

| Gap | Notes |
| --- | --- |
| Upstream RTL suite as-is | Needs DOM React (`button`, `getByRole`, `createBrowserHistory`) |
| Missing uSES Lynx alias | **Critical**: navigate updates `router.state` but UI never re-renders |
| `createBrowserHistory` | Lynx has no History API |
| `<Link>` / `<a>` / IO preload | Prefer `useNavigate` + `bindtap` |
| SSR / Scripts / hydration tests | Out of Lynx scope |
| Full MPA web-shell stack e2e | Needs PR #3 shell — not on main |
| Pure Node (no `window`) | `createRouter` touches `window.origin` even with memory history |
| Occasional web-core `padding` PAGEERROR | Noise; scenarios still pass |

## Commands

```bash
pnpm --filter @sparkling-example/tanstack-lynx-web-compat test:unit
pnpm --filter @sparkling-example/tanstack-lynx-web-compat test:e2e
```
25 changes: 25 additions & 0 deletions examples/tanstack-lynx-web-compat/harness/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TanStack × Lynx Web Compat Harness</title>
<style>
html,
body,
#root {
margin: 0;
width: 100%;
height: 100%;
background: #000;
}
lynx-view {
width: 100%;
height: 100%;
}
</style>
</head>
<body>
<div id="root"></div>
</body>
</html>
53 changes: 53 additions & 0 deletions examples/tanstack-lynx-web-compat/harness/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// 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.
/**
* Minimal Lynx-for-Web host (experimental).
* Uses mainline @lynx-js/web-core 0.20.x custom-element API (`/client` export),
* same surface the website go-web gallery uses. Shell stacking ideas from
* PR #3 / stack/3-sparkling-web-shell are intentionally out of scope here —
* this harness hosts a single soft-nav suite bundle.
*/
import '@lynx-js/web-core/client'
import '@lynx-js/web-core/client.prod.css'
import '@lynx-js/web-elements/all'
import '@lynx-js/web-elements/index.css'

const params = new URLSearchParams(window.location.search)
const bundle =
params.get('bundle') ||
params.get('url') ||
'/suite.web.bundle'

const root = document.getElementById('root')!
const view = document.createElement('lynx-view') as HTMLElement & {
url: string
globalProps?: Record<string, unknown>
}

view.setAttribute('style', 'width:100%;height:100%;')
const globalProps = {
containerID: 'compat-suite-1',
os: 'web',
queryItems: {
__path: params.get('__path') || '/',
// Auto-scenario runner (see suite-app.tsx). Avoids bindtap↔Playwright click gap.
...(params.get('scenario') ? { scenario: params.get('scenario')! } : {}),
},
screenWidth: window.innerWidth,
screenHeight: window.innerHeight,
pixelRatio: window.devicePixelRatio,
}
// Set props before url so the Worker boots with them (web-core 0.20).
view.globalProps = globalProps
try {
view.setAttribute('global-props', JSON.stringify(globalProps))
} catch {
// attribute optional
}
view.url = bundle

root.appendChild(view)

;(window as unknown as { __LYNX_VIEW__: unknown }).__LYNX_VIEW__ = view
console.log('[compat-harness] mounted lynx-view url=', bundle)
36 changes: 36 additions & 0 deletions examples/tanstack-lynx-web-compat/harness/rsbuild.config.ts
Original file line number Diff line number Diff line change
@@ -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 path from 'node:path'
import { fileURLToPath } from 'node:url'
import { defineConfig } from '@rsbuild/core'

const __dirname = path.dirname(fileURLToPath(import.meta.url))
const bundleDir = process.env.LYNX_BUNDLE_DIR || path.join(__dirname, '../dist')

export default defineConfig({
source: {
entry: {
index: path.join(__dirname, 'index.ts'),
},
},
html: {
title: 'TanStack × Lynx Web Compat',
template: path.join(__dirname, 'index.html'),
},
server: {
port: 4310,
publicDir: [
{
name: path.resolve(bundleDir),
copyOnBuild: true,
},
],
},
output: {
distPath: {
root: path.join(__dirname, '../harness-dist'),
},
assetPrefix: '/',
},
})
39 changes: 39 additions & 0 deletions examples/tanstack-lynx-web-compat/lynx.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Copyright (c) 2026 TikTok Pte. Ltd.
// Licensed under the Apache License Version 2.0 that can be found in the
// LICENSE file in the root directory of this source tree.
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { defineConfig } from '@lynx-js/rspeedy'
import { pluginReactLynx } from '@lynx-js/react-rsbuild-plugin'

const root = path.dirname(fileURLToPath(import.meta.url))

export default defineConfig({
environments: {
lynx: {},
web: {},
},
source: {
entry: {
suite: './src/suite-app.tsx',
},
},
resolve: {
alias: {
react$: path.resolve(root, 'src/react-lynx-shim.ts'),
'react-dom$': path.resolve(root, 'src/shims/react-dom.ts'),
// Critical: TanStack react-store subscriptions need Lynx's uSES build,
// otherwise navigate() updates router.state but the UI never re-renders.
'use-sync-external-store/shim/with-selector$':
'@lynx-js/use-sync-external-store/shim/with-selector',
'use-sync-external-store/shim$': '@lynx-js/use-sync-external-store/shim',
},
},
output: {
assetPrefix: '/',
filename: {
bundle: '[name].[platform].bundle',
},
},
plugins: [pluginReactLynx()],
})
42 changes: 42 additions & 0 deletions examples/tanstack-lynx-web-compat/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
{
"name": "@sparkling-example/tanstack-lynx-web-compat",
"version": "0.0.0",
"private": true,
"type": "module",
"description": "TanStack Router upstream-compat suite on experimental Lynx for Web + CompositeHistory",
"scripts": {
"build:app": "rspeedy build",
"build:harness": "rsbuild build --config harness/rsbuild.config.ts",
"build": "pnpm build:app && pnpm build:harness",
"dev:harness": "pnpm build:app && LYNX_BUNDLE_DIR=$PWD/dist rsbuild dev --config harness/rsbuild.config.ts",
"test:unit": "vitest run --config vitest.config.ts",
"test:e2e": "pnpm build && playwright test -c tests/playwright.config.ts",
"test": "pnpm test:unit && pnpm test:e2e",
"report": "node scripts/write-results.mjs"
},
"dependencies": {
"@lynx-js/react": "^0.116.2",
"@lynx-js/web-core": "^0.20.3",
"@lynx-js/web-elements": "^0.12.0",
"@tanstack/history": "1.161.6",
"@tanstack/react-router": "1.170.18",
"sparkling-router": "workspace:*",
"url-search-params-polyfill": "^8.2.5"
},
"devDependencies": {
"@lynx-js/react-rsbuild-plugin": "^0.12.7",
"@lynx-js/rspeedy": "^0.13.3",
"@lynx-js/types": "^3.7.0",
"@lynx-js/use-sync-external-store": "^1.5.0",
"@playwright/test": "^1.58.2",
"@rsbuild/core": "1.7.2",
"@types/node": "^22.10.0",
"@types/react": "^18.3.20",
"happy-dom": "^20.11.1",
"typescript": "^5.8.3",
"vitest": "^3.1.2"
},
"engines": {
"node": "^22 || ^24"
}
}
83 changes: 83 additions & 0 deletions examples/tanstack-lynx-web-compat/scripts/write-results.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// Copyright (c) 2026 TikTok Pte. Ltd.
// Licensed under the Apache License Version 2.0 that can be found in the
// LICENSE file in the root directory of this source tree.
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'

const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const artifacts = path.join(root, 'artifacts')

function readJson(file) {
try {
return JSON.parse(fs.readFileSync(file, 'utf8'))
} catch {
return null
}
}

const unit = readJson(path.join(artifacts, 'unit-results.json'))
const e2e = readJson(path.join(artifacts, 'e2e-results.json'))

const lines = []
lines.push('# TanStack Router × Lynx-for-Web Compat Results')
lines.push('')
lines.push(`Generated: ${new Date().toISOString()}`)
lines.push('')
lines.push('## Environment')
lines.push('')
lines.push('- Host: experimental Lynx for Web via `@lynx-js/web-core` `<lynx-view>` (mainline API; shell patterns from PR #3 / `stack/3-sparkling-web-shell`)')
lines.push('- Router: `@tanstack/react-router@1.170.18` + `sparkling-router` `CompositeHistory`')
lines.push('- Soft-nav only in e2e (single container, `memoryStack: true`)')
lines.push('')

lines.push('## Unit (headless ports of upstream cases)')
lines.push('')
if (unit?.numTotalTests != null) {
lines.push(`- Total: ${unit.numTotalTests}`)
lines.push(`- Passed: ${unit.numPassedTests}`)
lines.push(`- Failed: ${unit.numFailedTests}`)
lines.push(`- Skipped: ${unit.numPendingTests ?? 0}`)
} else {
lines.push('_No unit artifact — run `pnpm test:unit` first._')
}
lines.push('')

lines.push('## E2E (Playwright × lynx-view)')
lines.push('')
if (e2e?.stats) {
lines.push(`- Expected: ${e2e.stats.expected}`)
lines.push(`- Unexpected failures: ${e2e.stats.unexpected}`)
lines.push(`- Skipped: ${e2e.stats.skipped}`)
} else if (e2e) {
lines.push('```json')
lines.push(JSON.stringify(e2e, null, 2).slice(0, 2000))
lines.push('```')
} else {
lines.push('_No e2e artifact — run `pnpm test:e2e` first._')
}
lines.push('')

lines.push('## Upstream surface coverage map')
lines.push('')
lines.push('| Upstream area | Approach | Status |')
lines.push('| --- | --- | --- |')
lines.push('| `@tanstack/history` memory | direct port | see unit |')
lines.push('| `useNavigate` / `router.navigate` | headless + e2e | see unit/e2e |')
lines.push('| path params / search | headless + e2e | see unit/e2e |')
lines.push('| loaders / redirect / notFound | headless | see unit |')
lines.push('| `CompositeHistory` soft/hard | headless | see unit |')
lines.push('| `<Link>` / `<a>` / preload IO | probe only | **known gap on ReactLynx** |')
lines.push('| `createBrowserHistory` / History API | not applicable | Lynx has no History API; memory/Composite only |')
lines.push('| SSR / `renderRouterToStream` / Scripts | not run | out of Lynx Web scope |')
lines.push('| full upstream RTL suite (~250+ DOM tests) | not runnable as-is | needs `<div>`/`button`/`screen.getByRole` |')
lines.push('')

lines.push('## What works / what does not (narrative)')
lines.push('')
lines.push('See PR description — this file is machine-refreshed from artifacts.')
lines.push('')

fs.mkdirSync(artifacts, { recursive: true })
fs.writeFileSync(path.join(root, 'RESULTS.md'), lines.join('\n'))
console.log('Wrote RESULTS.md')
Loading
Loading