diff --git a/examples/router-demo/lynx.config.ts b/examples/router-demo/lynx.config.ts index d68a408c..09d56bbb 100644 --- a/examples/router-demo/lynx.config.ts +++ b/examples/router-demo/lynx.config.ts @@ -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: { diff --git a/examples/router-demo/package.json b/examples/router-demo/package.json index 378b2ea3..089fd1a5 100644 --- a/examples/router-demo/package.json +++ b/examples/router-demo/package.json @@ -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", diff --git a/examples/router-demo/src/shims/react-dom.ts b/examples/router-demo/src/shims/react-dom.ts new file mode 100644 index 00000000..5d6da73e --- /dev/null +++ b/examples/router-demo/src/shims/react-dom.ts @@ -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(fn: () => T): T { + return fn() +} +export default { flushSync } diff --git a/examples/tanstack-lynx-web-compat/.gitignore b/examples/tanstack-lynx-web-compat/.gitignore new file mode 100644 index 00000000..005da788 --- /dev/null +++ b/examples/tanstack-lynx-web-compat/.gitignore @@ -0,0 +1,6 @@ +dist/ +harness-dist/ +artifacts/ +test-results/ +playwright-report/ +node_modules/ diff --git a/examples/tanstack-lynx-web-compat/README.md b/examples/tanstack-lynx-web-compat/README.md new file mode 100644 index 00000000..fa1d97c0 --- /dev/null +++ b/examples/tanstack-lynx-web-compat/README.md @@ -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 `` via Playwright +3. **Records** known gaps (``/``, 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 +``` diff --git a/examples/tanstack-lynx-web-compat/RESULTS.md b/examples/tanstack-lynx-web-compat/RESULTS.md new file mode 100644 index 00000000..ba9594b3 --- /dev/null +++ b/examples/tanstack-lynx-web-compat/RESULTS.md @@ -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 × ``) | **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 | +| `` / `` / 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 +``` diff --git a/examples/tanstack-lynx-web-compat/harness/index.html b/examples/tanstack-lynx-web-compat/harness/index.html new file mode 100644 index 00000000..aad5458f --- /dev/null +++ b/examples/tanstack-lynx-web-compat/harness/index.html @@ -0,0 +1,25 @@ + + + + + + TanStack × Lynx Web Compat Harness + + + +
+ + diff --git a/examples/tanstack-lynx-web-compat/harness/index.ts b/examples/tanstack-lynx-web-compat/harness/index.ts new file mode 100644 index 00000000..b76ebd22 --- /dev/null +++ b/examples/tanstack-lynx-web-compat/harness/index.ts @@ -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 +} + +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) diff --git a/examples/tanstack-lynx-web-compat/harness/rsbuild.config.ts b/examples/tanstack-lynx-web-compat/harness/rsbuild.config.ts new file mode 100644 index 00000000..c7991277 --- /dev/null +++ b/examples/tanstack-lynx-web-compat/harness/rsbuild.config.ts @@ -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: '/', + }, +}) diff --git a/examples/tanstack-lynx-web-compat/lynx.config.ts b/examples/tanstack-lynx-web-compat/lynx.config.ts new file mode 100644 index 00000000..b3422396 --- /dev/null +++ b/examples/tanstack-lynx-web-compat/lynx.config.ts @@ -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()], +}) diff --git a/examples/tanstack-lynx-web-compat/package.json b/examples/tanstack-lynx-web-compat/package.json new file mode 100644 index 00000000..124405f1 --- /dev/null +++ b/examples/tanstack-lynx-web-compat/package.json @@ -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" + } +} diff --git a/examples/tanstack-lynx-web-compat/scripts/write-results.mjs b/examples/tanstack-lynx-web-compat/scripts/write-results.mjs new file mode 100644 index 00000000..95f0d448 --- /dev/null +++ b/examples/tanstack-lynx-web-compat/scripts/write-results.mjs @@ -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` `` (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('| `` / `
` / 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 `
`/`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') diff --git a/examples/tanstack-lynx-web-compat/src/react-lynx-shim.ts b/examples/tanstack-lynx-web-compat/src/react-lynx-shim.ts new file mode 100644 index 00000000..46ea43e5 --- /dev/null +++ b/examples/tanstack-lynx-web-compat/src/react-lynx-shim.ts @@ -0,0 +1,8 @@ +// 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 * from '@lynx-js/react' +export { default } from '@lynx-js/react' +export { startTransition, useTransition } from '@lynx-js/react/compat' +export { root } from '@lynx-js/react' +export const use = undefined as unknown as typeof import('react').use diff --git a/examples/tanstack-lynx-web-compat/src/shims/env.ts b/examples/tanstack-lynx-web-compat/src/shims/env.ts new file mode 100644 index 00000000..40387ac1 --- /dev/null +++ b/examples/tanstack-lynx-web-compat/src/shims/env.ts @@ -0,0 +1,16 @@ +// 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. +const g = globalThis as Record + +if (typeof g.scrollTo !== 'function') { + g.scrollTo = () => {} +} + +if (typeof g.queueMicrotask !== 'function') { + g.queueMicrotask = (cb: () => void) => { + Promise.resolve().then(cb) + } +} + +export {} diff --git a/examples/tanstack-lynx-web-compat/src/shims/react-dom.ts b/examples/tanstack-lynx-web-compat/src/shims/react-dom.ts new file mode 100644 index 00000000..9aae6793 --- /dev/null +++ b/examples/tanstack-lynx-web-compat/src/shims/react-dom.ts @@ -0,0 +1,9 @@ +// 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 react-dom shim — TanStack calls flushSync; Lynx has no react-dom. */ +export function flushSync(fn: () => T): T { + return fn() +} + +export default { flushSync } diff --git a/examples/tanstack-lynx-web-compat/src/styles.css b/examples/tanstack-lynx-web-compat/src/styles.css new file mode 100644 index 00000000..3dbebb36 --- /dev/null +++ b/examples/tanstack-lynx-web-compat/src/styles.css @@ -0,0 +1 @@ +/* intentionally empty — avoid inline style object bugs on some lynx-web builds */ diff --git a/examples/tanstack-lynx-web-compat/src/suite-app.tsx b/examples/tanstack-lynx-web-compat/src/suite-app.tsx new file mode 100644 index 00000000..9a8be28e --- /dev/null +++ b/examples/tanstack-lynx-web-compat/src/suite-app.tsx @@ -0,0 +1,237 @@ +// 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. +/** + * Lynx-for-Web visual suite app. + * Scenarios are driven imperatively on the router instance (reliable under + * Playwright). bindtap buttons remain for manual exploration. + */ +import 'url-search-params-polyfill' +import './shims/env.js' +import { root } from '@lynx-js/react' +import { + createRootRoute, + createRoute, + createRouter, + Outlet, + RouterProvider, + useNavigate, + useParams, + useRouterState, + useSearch, +} from '@tanstack/react-router' +import { + createSparklingHistory, + type RouteManifest, +} from 'sparkling-router' + +const manifest: RouteManifest = { + version: '1', + scheme: { base: 'hybrid://lynxview_page' }, + containers: [ + { + bundle: 'suite', + presentation: 'push', + routes: [ + { path: '/' }, + { path: '/about' }, + { path: '/posts' }, + { path: '/posts/$postId' }, + ], + }, + ], +} + +function queryItems(): Record { + try { + return ( + (lynx as { __globalProps?: { queryItems?: Record } }) + .__globalProps?.queryItems ?? {} + ) + } catch { + return {} + } +} + +let scenarioLabel = 'idle' + +function RootLayout() { + const state = useRouterState() + // Re-read label each render; scenario runner triggers navigations that re-render. + const label = + (state.location.search as { __scenario?: string } | undefined)?.__scenario || + scenarioLabel + return ( + + {`pathname=${state.location.pathname}`} + {`search=${JSON.stringify(state.location.search ?? {})}`} + {`scenarioProp=${queryItems().scenario || 'none'}`} + {`scenario=${label}`} + + + ) +} + +function IndexPage() { + const navigate = useNavigate() + return ( + + Index + navigate({ to: '/about', search: { from: 'index' } })}> + go-about + + navigate({ to: '/posts' })}> + go-posts + + + ) +} + +function AboutPage() { + const navigate = useNavigate() + const search = useSearch({ strict: false }) as { from?: string } + return ( + + {`About from=${search.from ?? '?'}`} + navigate({ to: '/' })}> + go-home + + + ) +} + +function PostsPage() { + const navigate = useNavigate() + return ( + + Posts + navigate({ to: '/posts/$postId', params: { postId: '42' } })} + > + go-post-42 + + navigate({ to: '..' })}> + go-back + + + ) +} + +function PostPage() { + const { postId } = useParams({ strict: false }) as { postId: string } + const navigate = useNavigate() + return ( + + {`Post ${postId}`} + navigate({ to: '/posts' })}> + go-posts + + + ) +} + +const rootRoute = createRootRoute({ + component: RootLayout, +}) +const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexPage, +}) +const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + validateSearch: (s: Record) => ({ + from: typeof s.from === 'string' ? s.from : undefined, + __scenario: typeof s.__scenario === 'string' ? s.__scenario : undefined, + }), + component: AboutPage, +}) +const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + validateSearch: (s: Record) => ({ + __scenario: typeof s.__scenario === 'string' ? s.__scenario : undefined, + }), + component: PostsPage, +}) +const postRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/posts/$postId', + validateSearch: (s: Record) => ({ + __scenario: typeof s.__scenario === 'string' ? s.__scenario : undefined, + }), + component: PostPage, +}) + +const runtime = createSparklingHistory({ + manifest, + container: { + bundle: 'suite', + ownedRoutes: ['/', '/about', '/posts', '/posts/$postId'], + presentation: 'push', + }, + memoryStack: true, + queryItems: queryItems(), +}) + +const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, aboutRoute, postsRoute, postRoute]), + history: runtime.history, + isServer: false, +}) + +async function runScenario(scenario: string) { + scenarioLabel = 'pending' + console.log('[suite] runScenario start', scenario) + await router.load() + if (scenario === 'about-search') { + await router.navigate({ to: '/about', search: { from: 'index' } }) + } else if (scenario === 'posts-params') { + await router.navigate({ to: '/posts' }) + await router.navigate({ to: '/posts/$postId', params: { postId: '42' } }) + } else if (scenario === 'soft-back') { + await router.navigate({ to: '/posts' }) + await router.history.back() + // allow memory history subscriber to settle + await new Promise((r) => setTimeout(r, 0)) + } else if (scenario === 'replace') { + await router.navigate({ to: '/posts' }) + await router.navigate({ + to: '/about', + search: { from: 'replace' }, + replace: true, + }) + } + scenarioLabel = `done:${scenario}` + console.log('[suite] runScenario done', scenario, router.state.location.pathname) + await router.navigate({ + to: router.state.location.pathname as '/', + search: { + ...(router.state.location.search as Record), + __scenario: scenarioLabel, + } as never, + replace: true, + }) +} + +const scenario = queryItems().scenario +if (scenario) { + scenarioLabel = 'pending' + // Wait for RouterProvider to subscribe before navigating. + setTimeout(() => { + void runScenario(scenario).catch((error) => { + scenarioLabel = `error:${String(error)}` + console.error('[suite] scenario failed', error) + // Force a no-op replace so UI can show the error label via search. + void router.navigate({ + to: '/', + search: { __scenario: scenarioLabel } as never, + replace: true, + }) + }) + }, 300) +} + +root.render() diff --git a/examples/tanstack-lynx-web-compat/tests/e2e/soft-nav.spec.ts b/examples/tanstack-lynx-web-compat/tests/e2e/soft-nav.spec.ts new file mode 100644 index 00000000..b37a236d --- /dev/null +++ b/examples/tanstack-lynx-web-compat/tests/e2e/soft-nav.spec.ts @@ -0,0 +1,65 @@ +// 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 { expect, test } from '@playwright/test' + +/** + * Real Lynx-for-Web soft-nav suite. + * + * Navigation is driven by `?scenario=` (in-app useNavigate), because Playwright + * click / touch on `` does not currently fire Lynx tap handlers + * inside `` (documented known gap). + */ + +async function waitForText(page: import('@playwright/test').Page, text: string) { + await expect(page.getByText(text, { exact: false })).toBeVisible({ + timeout: 30_000, + }) +} + +test.describe('Lynx Web soft navigation (TanStack + CompositeHistory)', () => { + test('boots at /', async ({ page }) => { + await page.goto('/?bundle=/suite.web.bundle') + await waitForText(page, 'pathname=/') + await waitForText(page, 'Index') + await waitForText(page, 'scenario=idle') + }) + + test('scenario about-search', async ({ page }) => { + await page.goto('/?bundle=/suite.web.bundle&scenario=about-search') + await waitForText(page, 'scenario=done:about-search') + await waitForText(page, 'pathname=/about') + await waitForText(page, 'from=index') + }) + + test('scenario posts-params', async ({ page }) => { + await page.goto('/?bundle=/suite.web.bundle&scenario=posts-params') + await waitForText(page, 'scenario=done:posts-params') + await waitForText(page, 'pathname=/posts/42') + await waitForText(page, 'Post 42') + }) + + test('scenario soft-back', async ({ page }) => { + await page.goto('/?bundle=/suite.web.bundle&scenario=soft-back') + await waitForText(page, 'scenario=done:soft-back') + await waitForText(page, 'pathname=/') + }) + + test('scenario replace', async ({ page }) => { + await page.goto('/?bundle=/suite.web.bundle&scenario=replace') + await waitForText(page, 'scenario=done:replace') + await waitForText(page, 'pathname=/about') + await waitForText(page, 'from=replace') + }) + + test('Playwright click triggers bindtap after use-sync-external-store alias', async ({ page }) => { + // Early runs without @lynx-js/use-sync-external-store made this look broken + // (navigate happened, UI did not re-render). With the alias, click→bindtap works. + await page.goto('/?bundle=/suite.web.bundle') + await waitForText(page, 'pathname=/') + await page.locator('x-text#go-about').click({ force: true }) + await waitForText(page, 'pathname=/about') + await waitForText(page, 'from=index') + }) +}) + diff --git a/examples/tanstack-lynx-web-compat/tests/playwright.config.ts b/examples/tanstack-lynx-web-compat/tests/playwright.config.ts new file mode 100644 index 00000000..f4293621 --- /dev/null +++ b/examples/tanstack-lynx-web-compat/tests/playwright.config.ts @@ -0,0 +1,34 @@ +// 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 { defineConfig, devices } from '@playwright/test' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') + +export default defineConfig({ + testDir: path.join(root, 'tests/e2e'), + timeout: 60_000, + fullyParallel: false, + retries: 0, + reporter: [['list'], ['json', { outputFile: path.join(root, 'artifacts/e2e-results.json') }]], + use: { + baseURL: 'http://127.0.0.1:4310', + trace: 'on-first-retry', + }, + webServer: { + // Serve the built harness + copied *.web.bundle (see pnpm build). + command: 'npx --yes serve harness-dist -l 4310 --no-port-switching', + cwd: root, + url: 'http://127.0.0.1:4310', + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}) diff --git a/examples/tanstack-lynx-web-compat/tests/unit/helpers.ts b/examples/tanstack-lynx-web-compat/tests/unit/helpers.ts new file mode 100644 index 00000000..53796fed --- /dev/null +++ b/examples/tanstack-lynx-web-compat/tests/unit/helpers.ts @@ -0,0 +1,136 @@ +// Copyright (c) 2026 TikTok Pte. Ltd. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import 'url-search-params-polyfill' +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + type AnyRouter, +} from '@tanstack/react-router' +import { + createCompositeHistory, + createInMemoryStackProtocol, + __setGlobalStackMirrorForTests, + GlobalStackMirror, + type RouteManifest, +} from 'sparkling-router' + +export const FEED_MANIFEST: RouteManifest = { + version: '1', + scheme: { base: 'hybrid://lynxview_page' }, + containers: [ + { + bundle: 'home', + presentation: 'push', + routes: [{ path: '/' }, { path: '/about' }, { path: '/posts' }, { path: '/posts/$postId' }], + }, + { + bundle: 'settings', + presentation: 'modal', + routes: [{ path: '/settings' }], + }, + ], +} + +export function resetMirror() { + __setGlobalStackMirrorForTests(new GlobalStackMirror()) +} + +/** Headless TanStack router (upstream-style) with memory history + isServer:false. */ +export function createHeadlessRouter(initial = '/') { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + validateSearch: (s: Record) => ({ + from: typeof s.from === 'string' ? s.from : undefined, + }), + }) + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + }) + const postRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/posts/$postId', + }) + const settingsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/settings', + }) + + const history = createMemoryHistory({ initialEntries: [initial] }) + const router = createRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + aboutRoute, + postsRoute, + postRoute, + settingsRoute, + ]), + history, + isServer: false, + }) + return { router: router as AnyRouter, history } +} + +/** Same tree, but history is CompositeHistory (Sparkling soft/hard boundary). */ +export function createCompositeRouter(initial = '/') { + resetMirror() + const stack = createInMemoryStackProtocol({ + manifest: FEED_MANIFEST, + initial: { path: '/' }, + }) + const history = createCompositeHistory({ + container: { + bundle: 'home', + ownedRoutes: ['/', '/about', '/posts', '/posts/$postId'], + presentation: 'push', + }, + manifest: FEED_MANIFEST, + stack, + initialEntries: [initial], + }) + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/' }) + const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + validateSearch: (s: Record) => ({ + from: typeof s.from === 'string' ? s.from : undefined, + }), + }) + const postsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/posts' }) + const postRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/posts/$postId', + }) + const settingsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/settings', + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + aboutRoute, + postsRoute, + postRoute, + settingsRoute, + ]), + history, + isServer: false, + }) + return { router: router as AnyRouter, history, stack } +} + +export async function settle(ms = 0) { + await new Promise((r) => setTimeout(r, ms)) +} diff --git a/examples/tanstack-lynx-web-compat/tests/unit/history-memory.upstream.test.ts b/examples/tanstack-lynx-web-compat/tests/unit/history-memory.upstream.test.ts new file mode 100644 index 00000000..de66e020 --- /dev/null +++ b/examples/tanstack-lynx-web-compat/tests/unit/history-memory.upstream.test.ts @@ -0,0 +1,80 @@ +// 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. +/** + * Ported from @tanstack/history createMemoryHistory.test.ts (upstream). + * Validates the soft-nav history substrate Lynx / Sparkling rely on. + */ +import { describe, expect, test } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' + +describe('upstream/@tanstack/history createMemoryHistory', () => { + test('back', () => { + const initialEntry = '/initial' + const history = createMemoryHistory({ initialEntries: [initialEntry] }) + history.push('/a') + history.push('/b') + history.push('/c') + history.back() + expect(history.location.pathname).toBe('/b') + history.back() + expect(history.location.pathname).toBe('/a') + history.back() + expect(history.location.pathname).toBe(initialEntry) + history.back() + expect(history.location.pathname).toBe(initialEntry) + }) + + test('forward', () => { + const history = createMemoryHistory({ initialEntries: ['/'] }) + history.push('/a') + history.push('/b') + history.push('/c') + history.back() + history.back() + expect(history.location.pathname).toBe('/a') + history.forward() + expect(history.location.pathname).toBe('/b') + history.forward() + expect(history.location.pathname).toBe('/c') + history.forward() + expect(history.location.pathname).toBe('/c') + }) + + test('push and back #1916', () => { + const history = createMemoryHistory({ initialEntries: ['/'] }) + history.push('/a') + history.push('/b') + history.push('/c') + history.back() + expect(history.location.pathname).toBe('/b') + history.push('/d') + expect(history.location.pathname).toBe('/d') + history.back() + expect(history.location.pathname).toBe('/b') + }) + + test('length', () => { + const history = createMemoryHistory({ initialEntries: ['/'] }) + expect(history.length).toBe(1) + history.push('/a') + expect(history.length).toBe(2) + history.replace('/b') + expect(history.length).toBe(2) + }) + + test('state', () => { + const history = createMemoryHistory({ initialEntries: ['/'] }) + history.push('/a', { i: 1 }) + expect((history.location.state as { i?: number }).i).toBe(1) + history.replace('/b', { i: 2 }) + expect((history.location.state as { i?: number }).i).toBe(2) + }) + + test('canGoBack', () => { + const history = createMemoryHistory({ initialEntries: ['/'] }) + expect(history.canGoBack()).toBe(false) + history.push('/a') + expect(history.canGoBack()).toBe(true) + }) +}) diff --git a/examples/tanstack-lynx-web-compat/tests/unit/link-assumptions.upstream.test.ts b/examples/tanstack-lynx-web-compat/tests/unit/link-assumptions.upstream.test.ts new file mode 100644 index 00000000..1f2220a0 --- /dev/null +++ b/examples/tanstack-lynx-web-compat/tests/unit/link-assumptions.upstream.test.ts @@ -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. +/** + * Documents Link-related upstream assumptions that diverge on ReactLynx. + * These are intentional "known gaps" probes — failures here are expected signals. + */ +import { describe, expect, test } from 'vitest' +import { createHeadlessRouter } from './helpers' + +describe('upstream/react-router Link assumptions (known Lynx gaps)', () => { + test('buildLocation resolves typed destinations (works without Link DOM)', async () => { + const { router } = createHeadlessRouter('/') + await router.load() + const loc = router.buildLocation({ + to: '/posts/$postId', + params: { postId: '7' }, + search: { tab: 'hot' }, + }) + expect(loc.pathname).toBe('/posts/7') + expect(loc.search).toMatchObject({ tab: 'hot' }) + }) + + test('PROBE: HTMLAnchorElement exists in happy-dom host, but ReactLynx Link still cannot rely on navigation', () => { + // Upstream Link renders . ReactLynx has no reliable anchor navigation even when + // the Lynx-for-Web host provides HTMLAnchorElement on the outer page. + // Soft-nav demos must use useNavigate + bindtap (or a Lynx-safe Link wrapper). + expect(typeof (globalThis as { HTMLAnchorElement?: unknown }).HTMLAnchorElement).toBe( + 'function', + ) + }) + + test('PROBE: IntersectionObserver present in happy-dom — Link viewport-preload may still no-op inside lynx-view', () => { + // Records host capability. Inside Worker/lynx-view the observer may be absent or inert. + expect(typeof (globalThis as { IntersectionObserver?: unknown }).IntersectionObserver).toBe( + 'function', + ) + }) +}) + diff --git a/examples/tanstack-lynx-web-compat/tests/unit/loaders-redirect.upstream.test.ts b/examples/tanstack-lynx-web-compat/tests/unit/loaders-redirect.upstream.test.ts new file mode 100644 index 00000000..21e55b8b --- /dev/null +++ b/examples/tanstack-lynx-web-compat/tests/unit/loaders-redirect.upstream.test.ts @@ -0,0 +1,114 @@ +// 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. +/** + * Ports of loaders / redirect / notFound headless behaviors from upstream: + * loaders.test.tsx, redirect.test.tsx, not-found.test.tsx + */ +import { describe, expect, test } from 'vitest' +import 'url-search-params-polyfill' +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + notFound, + redirect, +} from '@tanstack/react-router' + +describe('upstream/react-router loaders + redirect + notFound (headless)', () => { + test('loader data is available on match after navigate', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + loader: async () => ({ items: ['a', 'b'] }), + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postsRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + isServer: false, + }) + await router.load() + await router.navigate({ to: '/posts' }) + const match = router.state.matches.find((m) => m.pathname === '/posts') + expect(match?.loaderData).toEqual({ items: ['a', 'b'] }) + }) + + test('redirect in loader updates location', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const secretRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/secret', + beforeLoad: () => { + throw redirect({ to: '/' }) + }, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, secretRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + isServer: false, + }) + await router.load() + await router.navigate({ to: '/secret' }) + expect(router.state.location.pathname).toBe('/') + }) + + test('notFound thrown from beforeLoad sets notFound state', async () => { + const rootRoute = createRootRoute({ + notFoundComponent: () => null, + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const missingRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/missing-data', + beforeLoad: () => { + throw notFound() + }, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, missingRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + isServer: false, + defaultNotFoundComponent: () => null, + }) + await router.load() + await router.navigate({ to: '/missing-data' }) + // After notFound, status should reflect not-found handling + expect( + router.state.location.pathname === '/missing-data' || + router.state.status === 'notFound' || + router.state.matches.some((m) => (m as { status?: string }).status === 'notFound'), + ).toBe(true) + }) + + test('optional trailing navigation to unknown path keeps router alive', async () => { + const rootRoute = createRootRoute({ + notFoundComponent: () => null, + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + isServer: false, + defaultNotFoundComponent: () => null, + }) + await router.load() + await router.navigate({ to: '/nope' as '/' }) + expect(router.state.location.pathname).toBe('/nope') + }) +}) diff --git a/examples/tanstack-lynx-web-compat/tests/unit/optional-params.upstream.test.ts b/examples/tanstack-lynx-web-compat/tests/unit/optional-params.upstream.test.ts new file mode 100644 index 00000000..4b2c9cbd --- /dev/null +++ b/examples/tanstack-lynx-web-compat/tests/unit/optional-params.upstream.test.ts @@ -0,0 +1,63 @@ +// 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. +/** + * Ports inspired by optional-path-params.test.tsx + useCanGoBack.test.tsx + */ +import { describe, expect, test } from 'vitest' +import 'url-search-params-polyfill' +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from '@tanstack/react-router' + +describe('upstream/react-router optional params + canGoBack (headless)', () => { + test('optional path param present and absent', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/' }) + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/posts/{-$category}', + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postsRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + isServer: false, + }) + await router.load() + await router.navigate({ to: '/posts/$category', params: { category: 'tech' } as never }) + expect(router.state.location.pathname).toMatch(/\/posts/) + await router.navigate({ to: '/posts/$category', params: { category: undefined } as never }) + expect(router.state.location.pathname).toContain('/posts') + }) + + test('history.canGoBack mirrors upstream useCanGoBack substrate', async () => { + const history = createMemoryHistory({ initialEntries: ['/'] }) + expect(history.canGoBack()).toBe(false) + history.push('/a') + expect(history.canGoBack()).toBe(true) + history.back() + expect(history.canGoBack()).toBe(false) + }) + + test('relative navigate to parent', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/' }) + const postsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/posts' }) + const postRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/posts/$postId', + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postsRoute, postRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + isServer: false, + }) + await router.load() + await router.navigate({ to: '/posts/$postId', params: { postId: '1' } }) + await router.navigate({ to: '..' }) + expect(router.state.location.pathname).toBe('/posts') + }) +}) diff --git a/examples/tanstack-lynx-web-compat/tests/unit/router-navigate.upstream.test.ts b/examples/tanstack-lynx-web-compat/tests/unit/router-navigate.upstream.test.ts new file mode 100644 index 00000000..8251b3cd --- /dev/null +++ b/examples/tanstack-lynx-web-compat/tests/unit/router-navigate.upstream.test.ts @@ -0,0 +1,115 @@ +// 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. +/** + * Headless ports of high-signal cases from: + * - packages/react-router/tests/useNavigate.test.tsx + * - packages/react-router/tests/router.test.tsx + * - packages/react-router/tests/useParams.test.tsx + * + * Driven via router.navigate / router.state (no DOM / RTL). + */ +import { afterEach, beforeEach, describe, expect, test } from 'vitest' +import { + createCompositeRouter, + createHeadlessRouter, + resetMirror, + settle, +} from './helpers' + +describe('upstream/react-router navigate (headless, memory)', () => { + test('navigating to /posts', async () => { + const { router } = createHeadlessRouter('/') + await router.load() + await router.navigate({ to: '/posts' }) + expect(router.state.location.pathname).toBe('/posts') + }) + + test('navigating to /posts/$postId with params', async () => { + const { router } = createHeadlessRouter('/') + await router.load() + await router.navigate({ to: '/posts/$postId', params: { postId: 'id1' } }) + expect(router.state.location.pathname).toBe('/posts/id1') + const match = router.state.matches.find((m) => m.routeId.includes('postId')) + expect(match?.params).toMatchObject({ postId: 'id1' }) + }) + + test('navigating with search params', async () => { + const { router } = createHeadlessRouter('/') + await router.load() + await router.navigate({ to: '/about', search: { from: 'index' } }) + expect(router.state.location.pathname).toBe('/about') + expect(router.state.location.search).toMatchObject({ from: 'index' }) + }) + + test('replace navigation does not grow history', async () => { + const { router, history } = createHeadlessRouter('/') + await router.load() + await router.navigate({ to: '/posts' }) + const len = history.length + await router.navigate({ to: '/about', replace: true }) + expect(router.state.location.pathname).toBe('/about') + expect(history.length).toBe(len) + }) + + test('back after soft navigations', async () => { + const { router, history } = createHeadlessRouter('/') + await router.load() + await router.navigate({ to: '/posts' }) + await router.navigate({ to: '/posts/$postId', params: { postId: '9' } }) + history.back() + await settle(0) + expect(history.location.pathname).toBe('/posts') + }) + + test('emoji / unicode path params (router.test encoding sample)', async () => { + const { router } = createHeadlessRouter('/') + await router.load() + await router.navigate({ to: '/posts/$postId', params: { postId: '🚀' } }) + expect(router.state.location.pathname).toBe('/posts/🚀') + }) +}) + +describe('upstream/react-router navigate via CompositeHistory (Sparkling)', () => { + beforeEach(() => resetMirror()) + afterEach(() => resetMirror()) + + test('soft navigations stay in-container', async () => { + const { router, stack } = createCompositeRouter('/') + await router.load() + const before = (await stack.getState()).entries.length + await router.navigate({ to: '/posts' }) + await router.navigate({ to: '/posts/$postId', params: { postId: '42' } }) + expect(router.state.location.pathname).toBe('/posts/42') + const after = (await stack.getState()).entries.length + expect(after).toBe(before) + }) + + test('cross-boundary /settings triggers hard push', async () => { + const { router, stack } = createCompositeRouter('/') + await router.load() + await router.navigate({ to: '/settings' }) + await settle(10) + const state = await stack.getState() + expect(state.entries.map((e) => e.bundle)).toContain('settings') + }) + + test('soft back then hard pop at subtree root', async () => { + const home = createCompositeRouter('/') + await home.stack.push({ path: '/settings' }) + // Soft history at '/' cannot go back; canGoBack should still be true via stack depth. + expect(home.history.canGoBack()).toBe(true) + home.history.back() + await settle(10) + const state = await home.stack.getState() + expect(state.entries).toHaveLength(1) + expect(state.entries[0]!.bundle).toBe('home') + }) + + test('search params round-trip on soft nav', async () => { + const { router } = createCompositeRouter('/') + await router.load() + await router.navigate({ to: '/about', search: { from: 'home' } }) + expect(router.state.location.search).toMatchObject({ from: 'home' }) + }) +}) diff --git a/examples/tanstack-lynx-web-compat/tsconfig.json b/examples/tanstack-lynx-web-compat/tsconfig.json new file mode 100644 index 00000000..33d1b04f --- /dev/null +++ b/examples/tanstack-lynx-web-compat/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "preserve", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "types": ["node", "@lynx-js/types"] + }, + "include": ["src", "tests", "harness", "vitest.config.ts", "lynx.config.ts"] +} diff --git a/examples/tanstack-lynx-web-compat/vitest.config.ts b/examples/tanstack-lynx-web-compat/vitest.config.ts new file mode 100644 index 00000000..f48a22eb --- /dev/null +++ b/examples/tanstack-lynx-web-compat/vitest.config.ts @@ -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 { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + // TanStack RouterCore.update touches `window` even with memory history + // + isServer:false (upstream assumption). Lynx-for-Web provides window; + // plain Node does not — use happy-dom to mirror the web host. + environment: 'happy-dom', + include: ['tests/unit/**/*.test.ts'], + reporters: ['default', 'json'], + outputFile: { + json: './artifacts/unit-results.json', + }, + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a6dc8939..b08cde92 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -67,6 +67,9 @@ importers: '@lynx-js/types': specifier: ^3.7.0 version: 3.7.0 + '@lynx-js/use-sync-external-store': + specifier: ^1.5.0 + version: 1.5.0(@lynx-js/react@0.116.5(@lynx-js/types@3.7.0)(@types/react@18.3.28)) '@rsbuild/core': specifier: 1.7.2 version: 1.7.2 @@ -83,6 +86,64 @@ importers: specifier: ^5.8.3 version: 5.9.3 + examples/tanstack-lynx-web-compat: + dependencies: + '@lynx-js/react': + specifier: ^0.116.2 + version: 0.116.5(@lynx-js/types@3.7.0)(@types/react@18.3.28) + '@lynx-js/web-core': + specifier: ^0.20.3 + version: 0.20.4(@lynx-js/lynx-core@0.1.3)(tslib@2.8.1) + '@lynx-js/web-elements': + specifier: ^0.12.0 + version: 0.12.2(tslib@2.8.1) + '@tanstack/history': + specifier: 1.161.6 + version: 1.161.6 + '@tanstack/react-router': + specifier: 1.170.18 + version: 1.170.18(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + sparkling-router: + specifier: workspace:* + version: link:../../packages/sparkling-router + url-search-params-polyfill: + specifier: ^8.2.5 + version: 8.2.5 + devDependencies: + '@lynx-js/react-rsbuild-plugin': + specifier: ^0.12.7 + version: 0.12.10(@lynx-js/lynx-core@0.1.3)(@lynx-js/react@0.116.5(@lynx-js/types@3.7.0)(@types/react@18.3.28))(tslib@2.8.1)(webpack@5.105.0) + '@lynx-js/rspeedy': + specifier: ^0.13.3 + version: 0.13.6(@rspack/core@1.7.11(@swc/helpers@0.5.21))(typescript@5.9.3)(webpack@5.105.0) + '@lynx-js/types': + specifier: ^3.7.0 + version: 3.7.0 + '@lynx-js/use-sync-external-store': + specifier: ^1.5.0 + version: 1.5.0(@lynx-js/react@0.116.5(@lynx-js/types@3.7.0)(@types/react@18.3.28)) + '@playwright/test': + specifier: ^1.58.2 + version: 1.62.0 + '@rsbuild/core': + specifier: 1.7.2 + version: 1.7.2 + '@types/node': + specifier: ^22.10.0 + version: 22.19.17 + '@types/react': + specifier: ^18.3.20 + version: 18.3.28 + happy-dom: + specifier: ^20.11.1 + version: 20.11.1 + typescript: + specifier: ^5.8.3 + version: 5.9.3 + vitest: + specifier: ^3.1.2 + version: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.17)(happy-dom@20.11.1)(jiti@2.7.0)(jsdom@28.1.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3) + packages/create-sparkling-app: dependencies: '@clack/prompts': @@ -215,7 +276,7 @@ importers: version: 18.3.28 '@vitest/coverage-v8': specifier: ^3.1.2 - version: 3.2.4(vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.6.0)(jiti@2.7.0)(jsdom@26.1.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3)) + version: 3.2.4(vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.6.0)(happy-dom@20.11.1)(jiti@2.7.0)(jsdom@26.1.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3)) jsdom: specifier: ^26.1.0 version: 26.1.0 @@ -233,7 +294,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.1.2 - version: 3.2.4(@types/debug@4.1.13)(@types/node@25.6.0)(jiti@2.7.0)(jsdom@26.1.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3) + version: 3.2.4(@types/debug@4.1.13)(@types/node@25.6.0)(happy-dom@20.11.1)(jiti@2.7.0)(jsdom@26.1.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3) packages/sparkling-app-cli: dependencies: @@ -510,7 +571,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.18 - version: 4.1.4(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4)(jsdom@28.1.0)(vite@7.3.2(@types/node@25.6.0)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3)) + version: 4.1.4(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4)(happy-dom@20.11.1)(jsdom@28.1.0)(vite@7.3.2(@types/node@25.6.0)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3)) packages: @@ -1447,6 +1508,11 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@playwright/test@1.62.0': + resolution: {integrity: sha512-9zOJ6ZQRAena31MpOH9VSzIz8Ou3YJ/wtY/eQm5T2uhfhG7/U3COrMS8xOtUrZrp9OgdmzEnIYODye3nY1VqzA==} + engines: {node: '>=20'} + hasBin: true + '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} @@ -2342,6 +2408,9 @@ packages: '@types/use-sync-external-store@0.0.6': resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + '@types/whatwg-mimetype@3.0.2': + resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} + '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} @@ -2729,6 +2798,10 @@ packages: buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + buffer-image-size@0.6.4: + resolution: {integrity: sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==} + engines: {node: '>=4.0'} + bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} @@ -3225,6 +3298,10 @@ packages: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + envinfo@7.14.0: resolution: {integrity: sha512-CO40UI41xDQzhLB1hWyqUKgFhs250pNcGbyGKe1l/e4FSaI/+YE4IMG76GDt0In67WLPACIITC+sOi08x4wIvg==} engines: {node: '>=4'} @@ -3534,6 +3611,10 @@ packages: engines: {node: '>=0.4.7'} hasBin: true + happy-dom@20.11.1: + resolution: {integrity: sha512-XSt8tMzbW9ymE7687xztkO1ckR7qJNQ3LywY9vlYGhGi3zXrGBHuUo2Cl1ztZaICW+1eAGdkLbj6iwVqDT33kg==} + engines: {node: '>=20.0.0'} + has-bigints@1.1.0: resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} engines: {node: '>= 0.4'} @@ -3992,10 +4073,6 @@ packages: node-notifier: optional: true - jiti@2.6.1: - resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} - hasBin: true - jiti@2.7.0: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true @@ -4435,11 +4512,6 @@ packages: resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==} hasBin: true - nanoid@3.3.11: - resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - nanoid@3.3.16: resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -4628,11 +4700,21 @@ packages: engines: {node: '>=18'} hasBin: true + playwright-core@1.62.0: + resolution: {integrity: sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==} + engines: {node: '>=20'} + hasBin: true + playwright@1.59.1: resolution: {integrity: sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==} engines: {node: '>=18'} hasBin: true + playwright@1.62.0: + resolution: {integrity: sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==} + engines: {node: '>=20'} + hasBin: true + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -4806,10 +4888,6 @@ packages: postcss-value-parser@4.2.0: resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} - postcss@8.5.10: - resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==} - engines: {node: ^10 || ^12 || >=14} - postcss@8.5.17: resolution: {integrity: sha512-J7EF+8X+CzRPaJPOv9Ck2wNWJvGnnl3PcNPAdGg6GTLjyVpyQ0yATMSXRFRV01BviT/9Gwuc3rjEyJbDJG9a4w==} engines: {node: ^10 || ^12 || >=14} @@ -6078,6 +6156,10 @@ packages: engines: {node: '>=18'} deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + whatwg-mimetype@4.0.0: resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} engines: {node: '>=18'} @@ -6162,6 +6244,18 @@ packages: utf-8-validate: optional: true + ws@8.21.1: + resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + xml-name-validator@5.0.0: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} @@ -7352,6 +7446,10 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@playwright/test@1.62.0': + dependencies: + playwright: 1.62.0 + '@polka/url@1.0.0-next.29': {} '@rollup/rollup-android-arm-eabi@4.60.1': @@ -7435,7 +7533,7 @@ snapshots: '@rspack/lite-tapable': 1.1.0 '@swc/helpers': 0.5.21 core-js: 3.47.0 - jiti: 2.6.1 + jiti: 2.7.0 '@rsbuild/core@1.7.3': dependencies: @@ -7443,7 +7541,7 @@ snapshots: '@rspack/lite-tapable': 1.1.0 '@swc/helpers': 0.5.21 core-js: 3.47.0 - jiti: 2.6.1 + jiti: 2.7.0 '@rsbuild/core@2.0.0-rc.1(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(core-js@3.47.0)': dependencies: @@ -8553,6 +8651,8 @@ snapshots: '@types/use-sync-external-store@0.0.6': {} + '@types/whatwg-mimetype@3.0.2': {} + '@types/ws@8.18.1': dependencies: '@types/node': 22.19.17 @@ -8570,7 +8670,7 @@ snapshots: react: 19.2.5 unhead: 2.1.13 - '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.6.0)(jiti@2.7.0)(jsdom@26.1.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3))': + '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.6.0)(happy-dom@20.11.1)(jiti@2.7.0)(jsdom@26.1.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 @@ -8585,7 +8685,7 @@ snapshots: std-env: 3.10.0 test-exclude: 7.0.2 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.13)(@types/node@25.6.0)(jiti@2.7.0)(jsdom@26.1.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3) + vitest: 3.2.4(@types/debug@4.1.13)(@types/node@25.6.0)(happy-dom@20.11.1)(jiti@2.7.0)(jsdom@26.1.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3) transitivePeerDependencies: - supports-color @@ -8601,7 +8701,7 @@ snapshots: obug: 2.1.1 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.4(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4)(jsdom@28.1.0)(vite@7.3.2(@types/node@25.6.0)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3)) + vitest: 4.1.4(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4)(happy-dom@20.11.1)(jsdom@28.1.0)(vite@7.3.2(@types/node@25.6.0)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3)) '@vitest/expect@3.2.4': dependencies: @@ -8620,6 +8720,14 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 + '@vitest/mocker@3.2.4(vite@7.3.2(@types/node@22.19.17)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.2(@types/node@22.19.17)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3) + '@vitest/mocker@3.2.4(vite@7.3.2(@types/node@25.6.0)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3))': dependencies: '@vitest/spy': 3.2.4 @@ -9039,6 +9147,10 @@ snapshots: buffer-from@1.1.2: {} + buffer-image-size@0.6.4: + dependencies: + '@types/node': 22.19.17 + bytes@3.1.2: {} cac@6.7.14: {} @@ -9526,6 +9638,8 @@ snapshots: entities@6.0.1: {} + entities@7.0.1: {} + envinfo@7.14.0: {} error-ex@1.3.4: @@ -9928,6 +10042,19 @@ snapshots: optionalDependencies: uglify-js: 3.19.3 + happy-dom@20.11.1: + dependencies: + '@types/node': 22.19.17 + '@types/whatwg-mimetype': 3.0.2 + '@types/ws': 8.18.1 + buffer-image-size: 0.6.4 + entities: 7.0.1 + whatwg-mimetype: 3.0.0 + ws: 8.21.1 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + has-bigints@1.1.0: {} has-flag@4.0.0: {} @@ -10759,8 +10886,6 @@ snapshots: - supports-color - ts-node - jiti@2.6.1: {} - jiti@2.7.0: {} js-tokens@10.0.0: {} @@ -11471,8 +11596,6 @@ snapshots: mustache@4.2.0: {} - nanoid@3.3.11: {} - nanoid@3.3.16: {} natural-compare@1.4.0: {} @@ -11645,12 +11768,20 @@ snapshots: playwright-core@1.59.1: {} + playwright-core@1.62.0: {} + playwright@1.59.1: dependencies: playwright-core: 1.59.1 optionalDependencies: fsevents: 2.3.2 + playwright@1.62.0: + dependencies: + playwright-core: 1.62.0 + optionalDependencies: + fsevents: 2.3.2 + possible-typed-array-names@1.1.0: {} postcss-calc@10.1.1(postcss@8.5.17): @@ -11809,12 +11940,6 @@ snapshots: postcss-value-parser@4.2.0: {} - postcss@8.5.10: - dependencies: - nanoid: 3.3.11 - picocolors: 1.1.1 - source-map-js: 1.2.1 - postcss@8.5.17: dependencies: nanoid: 3.3.16 @@ -13092,6 +13217,27 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 + vite-node@3.2.4(@types/node@22.19.17)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.2(@types/node@22.19.17)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vite-node@3.2.4(@types/node@25.6.0)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3): dependencies: cac: 6.7.14 @@ -13113,12 +13259,29 @@ snapshots: - tsx - yaml + vite@7.3.2(@types/node@22.19.17)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3): + dependencies: + esbuild: 0.27.7 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.17 + rollup: 4.60.1 + tinyglobby: 0.2.16 + optionalDependencies: + '@types/node': 22.19.17 + fsevents: 2.3.3 + jiti: 2.7.0 + sass: 1.100.0 + sass-embedded: 1.100.0 + terser: 5.46.1 + yaml: 2.8.3 + vite@7.3.2(@types/node@25.6.0)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3): dependencies: esbuild: 0.27.7 fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 - postcss: 8.5.10 + postcss: 8.5.17 rollup: 4.60.1 tinyglobby: 0.2.16 optionalDependencies: @@ -13130,7 +13293,51 @@ snapshots: terser: 5.46.1 yaml: 2.8.3 - vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.6.0)(jiti@2.7.0)(jsdom@26.1.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3): + vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.17)(happy-dom@20.11.1)(jiti@2.7.0)(jsdom@28.1.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@7.3.2(@types/node@22.19.17)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.16 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.2(@types/node@22.19.17)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3) + vite-node: 3.2.4(@types/node@22.19.17)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/debug': 4.1.13 + '@types/node': 22.19.17 + happy-dom: 20.11.1 + jsdom: 28.1.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.6.0)(happy-dom@20.11.1)(jiti@2.7.0)(jsdom@26.1.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 @@ -13158,6 +13365,7 @@ snapshots: optionalDependencies: '@types/debug': 4.1.13 '@types/node': 25.6.0 + happy-dom: 20.11.1 jsdom: 26.1.0 transitivePeerDependencies: - jiti @@ -13173,7 +13381,7 @@ snapshots: - tsx - yaml - vitest@4.1.4(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4)(jsdom@28.1.0)(vite@7.3.2(@types/node@25.6.0)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3)): + vitest@4.1.4(@types/node@25.6.0)(@vitest/coverage-v8@4.1.4)(happy-dom@20.11.1)(jsdom@28.1.0)(vite@7.3.2(@types/node@25.6.0)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3)): dependencies: '@vitest/expect': 4.1.4 '@vitest/mocker': 4.1.4(vite@7.3.2(@types/node@25.6.0)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3)) @@ -13198,6 +13406,7 @@ snapshots: optionalDependencies: '@types/node': 25.6.0 '@vitest/coverage-v8': 4.1.4(vitest@4.1.4) + happy-dom: 20.11.1 jsdom: 28.1.0 transitivePeerDependencies: - msw @@ -13301,6 +13510,8 @@ snapshots: dependencies: iconv-lite: 0.6.3 + whatwg-mimetype@3.0.0: {} + whatwg-mimetype@4.0.0: {} whatwg-mimetype@5.0.0: {} @@ -13393,6 +13604,8 @@ snapshots: ws@8.20.0: {} + ws@8.21.1: {} + xml-name-validator@5.0.0: {} xmlchars@2.2.0: {}