diff --git a/docs/en/guide/sparkling-router-prototype.md b/docs/en/guide/sparkling-router-prototype.md new file mode 100644 index 00000000..403f1de3 --- /dev/null +++ b/docs/en/guide/sparkling-router-prototype.md @@ -0,0 +1,83 @@ +# Sparkling Router Prototype Findings + +Status: prototype +Date: 2026-07-25 + +This prototype evaluates a URL-first declarative router over +`sparkling-navigation`, with native containers as hard navigation boundaries +and memory history inside each container. + +## Conclusions + +### 1. Reuse TanStack core, but do not import it as a complete router API + +`@tanstack/router-core` contains the matching, loading, navigation, search, and +route-tree machinery. It intentionally does not export framework-neutral +`createRouter`, `createRoute`, or `createRootRoute` factories. TanStack's own +framework packages subclass `RouterCore`, `BaseRoute`, and `BaseRootRoute` to +provide those factories and framework extensions. + +For ReactLynx, the lowest-risk architecture is therefore: + +- use the official `@tanstack/react-router` binding; +- keep `@tanstack/router-core` and `@tanstack/history` pinned as the underlying + core contracts; +- implement only Sparkling's `RouterHistory`, serializable manifest, and native + host adapter. + +Creating a separate Sparkling subclass layer would copy internal framework +binding work and make upstreaming harder without improving the ReactLynx +authoring experience. + +### 2. TanStack file routing is the primary authoring path + +The official `@tanstack/router-generator` and +`@tanstack/router-plugin/rspack` work with Rspeedy. They should continue to own +`routeTree.gen.ts`, typed routes, params, search, loaders, and links. + +Sparkling adds one orthogonal compiler pass: + +- find `_container.tsx` or `_container.modal.tsx` boundaries; +- partition routes into native bundles; +- emit a serializable global manifest and an entry map. + +The prototype's compiler uses the TypeScript AST for static route/config +extraction. It does not evaluate application source. + +### 3. A Next app-directory frontend can target the same core + +The second scanner maps `app/**/page.tsx`, dynamic `[id]` segments, route groups, +and optional `container.ts` boundaries into the same manifest schema. Tests +prove equivalent TanStack and Next directory trees produce equivalent +container manifests. + +This validates "one core, two authoring frontends" at the routing-data layer. +It does not yet implement a Next runtime or compatibility components such as +`next/link`, `useRouter`, `loading.tsx`, and `error.tsx`. + +## Evidence + +- `sparkling-history`: 29 tests cover in-container history parity, native page + forwarding, scheme transport, and back-at-root behavior. +- `sparkling-router`: 3 tests run a real TanStack router over Sparkling history, + including cross-container navigation. +- `sparkling-router-plugin`: 3 tests cover TanStack boundaries, equivalent Next + output, and non-evaluating AST extraction. +- `tanstack-router-demo`: 16 tests cover generated route trees, params, search, + loaders, redirects, errors, blockers, and MPA forwarding. +- Rspeedy builds four native bundles successfully: `spike`, `home`, `detail`, + and `settings`. + +## Remaining Gate + +This prototype does not freeze the native stack protocol. The next gate is the +iOS minimum implementation and conformance test for: + +- stack state and monotonic versions; +- push, pop, replace, reset, and getState; +- native gesture-driven `stackchanged`; +- `syncOwnLocation`; +- prefetch and result delivery. + +Only after that gate should `sparkling-history` be promoted to the full +`CompositeHistory` described by the RFC. diff --git a/packages/sparkling-history/README.md b/packages/sparkling-history/README.md new file mode 100644 index 00000000..1db68b43 --- /dev/null +++ b/packages/sparkling-history/README.md @@ -0,0 +1,79 @@ +# sparkling-history + +A reusable **web-history shim** that lets URL-driven routers — TanStack Router, +React Router, or your own — drive **native multi-page navigation**, where each +route subtree runs in its own container / JS context. + +This is the opposite of the SPA-in-one-view model: instead of one long-lived +router in one JS heap, each page is a separate view/VM, and navigating between +pages is a native container `open`. Because pages cannot share memory, they are +connected by pre-generated file-based metadata (a route→page manifest) rather +than an in-process history stack. + +## Layers + +``` + your router (TanStack Router / React Router / ...) + │ consumes a RouterHistory + ┌───────────▼───────────┐ + │ createMpaHistory │ web-history shim (this package) + └───────────┬───────────┘ + │ calls a NavigationHost + ┌────────────────▼─────────────────┐ + │ createSparklingHost / your own │ platform binding + └────────────────┬─────────────────┘ + │ + native container (sparkling-navigation) +``` + +- **`NavigationHost`** — the contract a platform implements: + `getInitialHref()`, `getStackDepth()`, `getInitialState()`, `open()`, + `close()`. Anything satisfying it can host a URL-driven router. +- **`createMpaHistory(opts)`** — implements the `RouterHistory` shape from + `@tanstack/history`, so its result can be passed straight to + `createRouter({ history })`. In-page navigations behave like a memory + history; cross-page navigations (decided by a `PageResolver`) are forwarded + to `host.open()`; `back()` at the page root becomes `host.close()`. +- **`createSparklingHost(opts)`** (`sparkling-history/sparkling`) — the + `sparkling-navigation` binding. + +## Usage + +```ts +import { createRouter } from '@tanstack/react-router'; +import { createMpaHistory, createManifestPageResolver } from 'sparkling-history'; +import { createSparklingHost } from 'sparkling-history/sparkling'; +import * as navigation from 'sparkling-navigation'; +import { routeTree, manifest } from './routes'; + +const host = createSparklingHost({ + navigation, + getQueryItems: () => lynx.__globalProps.queryItems, +}); + +const router = createRouter({ + routeTree, + history: createMpaHistory({ host, resolvePage: createManifestPageResolver(manifest) }), + isServer: false, + origin: 'http://sparkling.local', // router-core reads window.origin otherwise +}); +``` + +## Key behaviors + +| You call | In-page (same page) | Cross-page (different page) | +| --- | --- | --- | +| `router.navigate({ to })` | memory-history push/replace | `host.open()` → native page open | +| `router.history.back()` | local pop | `host.close()` → native pop | +| initial location | seeded from `getInitialHref()` | same — each page boots from its launch params | + +`__TSR_index` is seeded with the native stack depth so `canGoBack()` / +`useCanGoBack()` stay correct across page boundaries. Navigation blockers run +with **no** global `document` (unlike `@tanstack/history`, which gates blocker +execution on `typeof document !== 'undefined'`). + +## Tests + +`pnpm --filter sparkling-history test` — 28 tests: in-page parity with +`@tanstack/history`'s memory history (ported verbatim), MPA boundary behavior, +and sparkling scheme round-tripping, all in a plain node environment. diff --git a/packages/sparkling-history/package.json b/packages/sparkling-history/package.json new file mode 100644 index 00000000..745dae23 --- /dev/null +++ b/packages/sparkling-history/package.json @@ -0,0 +1,50 @@ +{ + "name": "sparkling-history", + "version": "2.1.0-rc.12", + "description": "Reusable web-history shim that lets URL-driven routers (TanStack Router, React Router, ...) drive native multi-page navigation through a pluggable NavigationHost contract", + "homepage": "https://tiktok.github.io/sparkling/", + "repository": { + "type": "git", + "url": "https://github.com/tiktok/sparkling", + "directory": "packages/sparkling-history" + }, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist", + "README.md" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./sparkling": { + "types": "./dist/hosts/sparkling.d.ts", + "default": "./dist/hosts/sparkling.js" + } + }, + "typesVersions": { + "*": { + "sparkling": ["dist/hosts/sparkling.d.ts"] + } + }, + "scripts": { + "build": "tsc", + "test": "vitest run" + }, + "peerDependencies": { + "sparkling-navigation": "*" + }, + "peerDependenciesMeta": { + "sparkling-navigation": { + "optional": true + } + }, + "devDependencies": { + "sparkling-navigation": "workspace:*", + "typescript": "^5.8.3", + "vitest": "^3.2.4" + }, + "license": "Apache-2.0" +} diff --git a/packages/sparkling-history/src/create-mpa-history.ts b/packages/sparkling-history/src/create-mpa-history.ts new file mode 100644 index 00000000..aff83b46 --- /dev/null +++ b/packages/sparkling-history/src/create-mpa-history.ts @@ -0,0 +1,259 @@ +// 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 { assignKeyAndIndex, parseHref } from './parse-href.js'; +import type { + CreateMpaHistoryOptions, + HistoryAction, + HistoryLocation, + MpaHistory, + NavigateOptions, + NavigationBlocker, + ParsedHistoryState, + SubscriberArgs, + SubscriberHistoryAction, +} from './types.js'; + +/** + * Create an MPA-aware history over a {@link NavigationHost}. + * + * Semantics (mirroring @tanstack/history's memory history for the in-page + * subset, extended with page-boundary behavior): + * + * - The history owns an *in-page* entry stack, seeded with the href the + * host was launched with. `location.state.__TSR_index` is global: + * `hostDepth + localIndex`. + * - `push`/`replace` consult `resolvePage(href)`. In-page destinations + * mutate the local stack and notify subscribers. Cross-page destinations + * are forwarded to `host.open(...)` and the local stack is untouched — + * the current page keeps rendering until the container covers or + * replaces it (document-navigation semantics). + * - `back()` below the local root forwards to `host.close()` (native pop). + * `forward()`/`go(+n)` beyond the local top is a no-op: the native + * forward stack does not exist. + * - Blockers run for JS-initiated navigation in any environment (no + * `typeof document` gate, unlike @tanstack/history). They cannot + * intercept container-initiated back (hardware/gesture/nav-bar). + */ +export function createMpaHistory(options: CreateMpaHistoryOptions): MpaHistory { + const { host, resolvePage, onHostError } = options; + + const hostDepth = host.getStackDepth?.() ?? 0; + + const initialHref = options.initialHref ?? host.getInitialHref(); + const initialHostState = host.getInitialState?.(); + const entries: Array = [initialHref]; + const states: Array = [ + assignKeyAndIndex( + hostDepth, + initialHostState && typeof initialHostState === 'object' + ? (initialHostState as Record) + : undefined, + ), + ]; + let index = 0; + + let location: HistoryLocation = parseHref(entries[index]!, states[index]); + const subscribers = new Set<(opts: SubscriberArgs) => void>(); + let blockers: Array = []; + + const getLocation = () => parseHref(entries[index]!, states[index]); + + const history: MpaHistory = { + get location() { + return location; + }, + get length() { + return entries.length; + }, + subscribers, + subscribe(cb) { + subscribers.add(cb); + return () => { + subscribers.delete(cb); + }; + }, + push(path, state, navigateOpts) { + const nextState = assignKeyAndIndex( + (location.state.__TSR_index ?? 0) + 1, + state as Record | undefined, + ); + void tryNavigation( + { + type: 'PUSH', + path, + state: nextState, + }, + navigateOpts, + () => { + const page = resolvePage?.(path, { currentHref: location.href }) ?? null; + if (page) { + const { key: _k, __TSR_key: _tk, __TSR_index: _ti, ...userState } = nextState; + callHost(() => + host.open({ + href: path, + page, + replace: false, + state: Object.keys(userState).length ? userState : undefined, + }), + ); + return; + } + // Start a new branch: drop any forward entries. + if (index < entries.length - 1) { + entries.splice(index + 1); + states.splice(index + 1); + } + entries.push(path); + states.push(nextState); + index = entries.length - 1; + notify({ type: 'PUSH' }); + }, + ); + }, + replace(path, state, navigateOpts) { + const nextState = assignKeyAndIndex( + location.state.__TSR_index ?? 0, + state as Record | undefined, + ); + void tryNavigation( + { + type: 'REPLACE', + path, + state: nextState, + }, + navigateOpts, + () => { + const page = resolvePage?.(path, { currentHref: location.href }) ?? null; + if (page) { + const { key: _k, __TSR_key: _tk, __TSR_index: _ti, ...userState } = nextState; + callHost(() => + host.open({ + href: path, + page, + replace: true, + state: Object.keys(userState).length ? userState : undefined, + }), + ); + return; + } + entries[index] = path; + states[index] = nextState; + notify({ type: 'REPLACE' }); + }, + ); + }, + go(n, navigateOpts) { + void tryNavigation({ type: 'GO' }, navigateOpts, () => { + const target = index + n; + if (target < 0) { + // Walk off the local root: pop the native page. Only a single + // native pop is supported per go() — deeper multi-page jumps + // cannot be expressed with sparkling's close-one primitive. + callHost(() => host.close()); + return; + } + index = Math.min(target, entries.length - 1); + notify({ type: 'GO', index: n }); + }); + }, + back(navigateOpts) { + void tryNavigation({ type: 'BACK' }, navigateOpts, () => { + if (index === 0) { + callHost(() => host.close()); + return; + } + index = Math.max(index - 1, 0); + notify({ type: 'BACK' }); + }); + }, + forward(navigateOpts) { + void tryNavigation({ type: 'FORWARD' }, navigateOpts, () => { + // Clamp to the local top; there is no native forward stack. + index = Math.min(index + 1, entries.length - 1); + notify({ type: 'FORWARD' }); + }); + }, + canGoBack() { + return (location.state.__TSR_index ?? 0) !== 0; + }, + createHref(str) { + return str; + }, + block(blocker) { + blockers = [...blockers, blocker]; + return () => { + blockers = blockers.filter((b) => b !== blocker); + }; + }, + flush() { + // In-memory: nothing to flush. + }, + destroy() { + subscribers.clear(); + blockers = []; + }, + notify(action: SubscriberHistoryAction) { + notify(action); + }, + }; + + function notify(action: SubscriberHistoryAction) { + location = getLocation(); + if (history._ignoreSubscribers) return; + subscribers.forEach((subscriber) => subscriber({ location, action })); + } + + function callHost(fn: () => void | Promise<{ ok: boolean; message?: string }>) { + try { + const result = fn(); + if (result && typeof (result as Promise).then === 'function') { + (result as Promise<{ ok: boolean; message?: string }>).then( + (res) => { + if (res && res.ok === false) { + onHostError?.(new Error(res.message ?? 'Navigation host rejected the request')); + } + }, + (err) => onHostError?.(err), + ); + } + } catch (err) { + onHostError?.(err); + } + } + + async function tryNavigation( + actionInfo: + | { type: 'PUSH' | 'REPLACE'; path: string; state: ParsedHistoryState } + | { type: Exclude }, + navigateOpts: NavigateOptions | undefined, + task: () => void, + ): Promise { + const ignoreBlocker = navigateOpts?.ignoreBlocker ?? false; + if (ignoreBlocker || blockers.length === 0) { + task(); + return; + } + + // Unlike @tanstack/history, blockers are evaluated in any JS + // environment (their implementation gates on `typeof document`). + for (const blocker of blockers) { + const nextLocation = + actionInfo.type === 'PUSH' || actionInfo.type === 'REPLACE' + ? parseHref(actionInfo.path, actionInfo.state) + : location; + const isBlocked = await blocker.blockerFn({ + currentLocation: location, + nextLocation, + action: actionInfo.type, + }); + if (isBlocked) { + return; + } + } + + task(); + } + + return history; +} diff --git a/packages/sparkling-history/src/hosts/memory.ts b/packages/sparkling-history/src/hosts/memory.ts new file mode 100644 index 00000000..07e27272 --- /dev/null +++ b/packages/sparkling-history/src/hosts/memory.ts @@ -0,0 +1,56 @@ +// 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 type { + HostCloseOptions, + HostOpenTarget, + NavigationHost, +} from '../types.js'; + +export interface RecordedOpen extends HostOpenTarget {} +export interface RecordedClose extends HostCloseOptions {} + +export interface MemoryHost extends NavigationHost { + /** Every open() the router asked the host to perform. */ + readonly opens: Array; + /** Every close() the router asked the host to perform. */ + readonly closes: Array; +} + +export interface MemoryHostOptions { + initialHref?: string; + stackDepth?: number; + initialState?: unknown; +} + +/** + * An in-memory {@link NavigationHost} that records the cross-page open/close + * calls a router makes, instead of touching any real container. This is the + * host used by the package's own tests and by the demo's headless + * verification: it makes the boundary between "in-page SPA navigation" and + * "native page open" observable and assertable. + */ +export function createMemoryHost(options: MemoryHostOptions = {}): MemoryHost { + const opens: Array = []; + const closes: Array = []; + + return { + opens, + closes, + getInitialHref() { + return options.initialHref ?? '/'; + }, + getStackDepth() { + return options.stackDepth ?? 0; + }, + getInitialState() { + return options.initialState; + }, + open(target) { + opens.push(target); + }, + close(opts) { + closes.push(opts ?? {}); + }, + }; +} diff --git a/packages/sparkling-history/src/hosts/sparkling.ts b/packages/sparkling-history/src/hosts/sparkling.ts new file mode 100644 index 00000000..523a623c --- /dev/null +++ b/packages/sparkling-history/src/hosts/sparkling.ts @@ -0,0 +1,177 @@ +// 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. +// +// A NavigationHost backed by sparkling-navigation. This is the concrete +// binding that lets a web-history-driven router perform *native* multi-page +// navigation: each route subtree lives in its own LynxView/JS context, and +// cross-page hrefs are turned into sparkling schemes opened by the native +// router. +import type { + HostCloseOptions, + HostNavigationResult, + HostOpenTarget, + NavigationHost, +} from '../types.js'; + +/** + * The subset of `sparkling-navigation` this host needs. Declared structurally + * so the package does not hard-depend on it (peer, optional) and so tests can + * inject a fake. + */ +export interface SparklingNavigationApi { + open( + params: { scheme: string; options?: Record }, + callback: (result: { code: number; msg: string }) => void, + ): void; + close( + params: { animated?: boolean } | undefined, + callback: (result: { code: number; msg: string }) => void, + ): void; +} + +export interface SparklingHostOptions { + /** + * The sparkling-navigation module (`{ open, close }`). Injected rather than + * imported so this package stays dependency-light and unit-testable. + */ + navigation: SparklingNavigationApi; + /** + * Reads this page's launch query params. On a real page pass + * `() => lynx.__globalProps.queryItems`. The router's initial location is + * reconstructed from these (see {@link readInitialHref}). + */ + getQueryItems?: () => Record | undefined; + /** + * The query-item key that carries the app-relative href of the current + * page. Defaults to `__mpa_href`. The host writes it when opening a page + * and reads it back to seed the destination router's initial location. + */ + hrefParam?: string; + /** The query-item key that carries the native stack depth. Default `__mpa_depth`. */ + depthParam?: string; + /** The query-item key that carries serialized navigation state. Default `__mpa_state`. */ + stateParam?: string; + /** Base sparkling scheme host. Default `hybrid://lynxview_page`. */ + baseScheme?: string; + /** + * Maps a resolved page id to its bundle path. Default: `.lynx.bundle` + * with any leading slash of the id stripped. + */ + bundleForPage?: (pageId: string) => string; + /** + * Whether native page opens/closes animate. Passed straight through to + * sparkling-navigation's `animated` option, so the *native container* runs + * its own push/pop transition — page-transition animation is a container + * concern, not something the app renders. Defaults to `true`. A per-call + * `close({ animated })` still overrides this for that pop. + */ + animated?: boolean; +} + +const DEFAULT_HREF_PARAM = '__mpa_href'; +const DEFAULT_DEPTH_PARAM = '__mpa_depth'; +const DEFAULT_STATE_PARAM = '__mpa_state'; +const DEFAULT_BASE_SCHEME = 'hybrid://lynxview_page'; + +function defaultBundleForPage(pageId: string): string { + const id = pageId.replace(/^\//, ''); + return id.endsWith('.lynx.bundle') ? id : `${id}.lynx.bundle`; +} + +/** + * Build a sparkling `NavigationHost`. + * + * @example + * ```ts + * import * as navigation from 'sparkling-navigation'; + * const host = createSparklingHost({ + * navigation, + * getQueryItems: () => lynx.__globalProps.queryItems, + * }); + * const history = createMpaHistory({ host, resolvePage }); + * ``` + */ +export function createSparklingHost(options: SparklingHostOptions): NavigationHost { + const { + navigation, + getQueryItems, + hrefParam = DEFAULT_HREF_PARAM, + depthParam = DEFAULT_DEPTH_PARAM, + stateParam = DEFAULT_STATE_PARAM, + baseScheme = DEFAULT_BASE_SCHEME, + bundleForPage = defaultBundleForPage, + animated = true, + } = options; + + const query = () => getQueryItems?.() ?? {}; + + function buildScheme(target: HostOpenTarget, depth: number): string { + const bundle = bundleForPage(target.page.id); + const url = new URL(baseScheme); + url.searchParams.set('bundle', bundle); + + // Static container config resolved before the page boots. + for (const [key, value] of Object.entries(target.page.containerParams ?? {})) { + url.searchParams.set(key, value); + } + + // MPA transport params: the destination router reconstructs its initial + // location from these. + url.searchParams.set(hrefParam, target.href); + url.searchParams.set(depthParam, String(depth)); + if (target.state !== undefined) { + url.searchParams.set(stateParam, JSON.stringify(target.state)); + } + + // sparkling's native URL parser wants %20, not + for spaces. + return url.toString().replace(/\+/g, '%20'); + } + + return { + getInitialHref() { + const q = query(); + const href = q[hrefParam]; + if (typeof href === 'string' && href.length > 0) return href; + // Fall back to '/', the router's root. + return '/'; + }, + + getStackDepth() { + const q = query(); + const raw = q[depthParam]; + const parsed = raw !== undefined ? Number.parseInt(raw, 10) : 0; + return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0; + }, + + getInitialState() { + const q = query(); + const raw = q[stateParam]; + if (typeof raw !== 'string' || raw.length === 0) return undefined; + try { + return JSON.parse(raw); + } catch { + return undefined; + } + }, + + open(target: HostOpenTarget): Promise { + const depth = this.getStackDepth!() + 1; + const scheme = buildScheme(target, depth); + return new Promise((resolve) => { + navigation.open( + { scheme, options: { replace: target.replace, animated } }, + (result) => resolve({ ok: result.code === 1, message: result.msg }), + ); + }); + }, + + close(opts?: HostCloseOptions): Promise { + return new Promise((resolve) => { + navigation.close({ animated: opts?.animated ?? animated }, (result) => + resolve({ ok: result.code === 1, message: result.msg }), + ); + }); + }, + }; +} diff --git a/packages/sparkling-history/src/index.ts b/packages/sparkling-history/src/index.ts new file mode 100644 index 00000000..6e5e7da9 --- /dev/null +++ b/packages/sparkling-history/src/index.ts @@ -0,0 +1,44 @@ +// 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. +// +// sparkling-history: a reusable web-history shim that lets URL-driven routers +// (TanStack Router, React Router, ...) drive native multi-page navigation. +// +// Layering: +// NavigationHost (contract) -> createMpaHistory (web-history shim) -> router +// ^ implemented by ^ consumed by +// createSparklingHost / createMemoryHost / your own +// +// The shim implements the `RouterHistory` shape from `@tanstack/history`, so +// the result of `createMpaHistory` can be passed straight to +// `createRouter({ history })`. + +export { createMpaHistory } from './create-mpa-history.js'; +export { parseHref, sanitizePath, assignKeyAndIndex, createRandomKey } from './parse-href.js'; +export { + createManifestPageResolver, + type PageManifest, + type PageManifestEntry, +} from './resolve-page.js'; +export { createMemoryHost, type MemoryHost, type MemoryHostOptions } from './hosts/memory.js'; + +export type { + HistoryLocation, + ParsedHistoryState, + HistoryAction, + SubscriberArgs, + SubscriberHistoryAction, + NavigateOptions, + BlockerFn, + BlockerFnArgs, + NavigationBlocker, + MpaHistory, + NavigationHost, + PageTarget, + PageResolver, + HostOpenTarget, + HostCloseOptions, + HostNavigationResult, + CreateMpaHistoryOptions, +} from './types.js'; diff --git a/packages/sparkling-history/src/parse-href.ts b/packages/sparkling-history/src/parse-href.ts new file mode 100644 index 00000000..97e36328 --- /dev/null +++ b/packages/sparkling-history/src/parse-href.ts @@ -0,0 +1,68 @@ +// 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. +// +// Href parsing, ported from @tanstack/history (MIT) so locations produced by +// this package are bit-compatible with what TanStack Router expects. +import type { HistoryLocation, ParsedHistoryState } from './types.js'; + +/** + * Sanitize a path to prevent open-redirect vulnerabilities: strips ASCII + * control characters and collapses leading double slashes. + */ +export function sanitizePath(path: string): string { + // eslint-disable-next-line no-control-regex + let sanitized = path.replace(/[\u0000-\u001F\u007F]/g, ''); + if (sanitized.startsWith('//')) { + sanitized = '/' + sanitized.replace(/^\/+/, ''); + } + return sanitized; +} + +export function createRandomKey(): string { + return (Math.random() + 1).toString(36).substring(7); +} + +export function assignKeyAndIndex( + index: number, + state: Record | undefined, +): ParsedHistoryState { + const key = createRandomKey(); + return { + ...(state ?? {}), + key, // TODO(upstream): remove in v2 — use __TSR_key instead + __TSR_key: key, + __TSR_index: index, + } as ParsedHistoryState; +} + +export function parseHref( + href: string, + state: ParsedHistoryState | undefined, +): HistoryLocation { + const sanitizedHref = sanitizePath(href); + const hashIndex = sanitizedHref.indexOf('#'); + const searchIndex = sanitizedHref.indexOf('?'); + + const addedKey = createRandomKey(); + + return { + href: sanitizedHref, + pathname: sanitizedHref.substring( + 0, + hashIndex > 0 + ? searchIndex > 0 + ? Math.min(hashIndex, searchIndex) + : hashIndex + : searchIndex > 0 + ? searchIndex + : sanitizedHref.length, + ), + hash: hashIndex > -1 ? sanitizedHref.substring(hashIndex) : '', + search: + searchIndex > -1 + ? sanitizedHref.slice(searchIndex, hashIndex === -1 ? undefined : hashIndex) + : '', + state: state || { __TSR_index: 0, key: addedKey, __TSR_key: addedKey }, + }; +} diff --git a/packages/sparkling-history/src/resolve-page.ts b/packages/sparkling-history/src/resolve-page.ts new file mode 100644 index 00000000..3c2bc9a7 --- /dev/null +++ b/packages/sparkling-history/src/resolve-page.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. +import type { PageResolver, PageTarget } from './types.js'; + +export interface PageManifestEntry { + /** Page id (bundle basename on sparkling). */ + id: string; + /** + * Path prefixes owned by this page. The longest matching prefix across all + * entries wins. Use `/` for the root page (matches everything not claimed + * by a more specific page). + */ + paths: Array; + /** Static container config applied when opening this page. */ + containerParams?: Record; +} + +/** + * 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. + */ +export interface PageManifest { + pages: Array; +} + +function pathnameOf(href: string): string { + const q = href.indexOf('?'); + const h = href.indexOf('#'); + let end = href.length; + if (q > -1) end = Math.min(end, q); + if (h > -1) end = Math.min(end, h); + return href.substring(0, end) || '/'; +} + +function matchLen(pathname: string, prefix: string): number { + if (prefix === '/') return pathname === '/' ? 1 : 0.5; // root is the fallback + if (pathname === prefix) return prefix.length + 1; + if (pathname.startsWith(prefix.endsWith('/') ? prefix : prefix + '/')) { + return prefix.length; + } + return 0; +} + +/** + * Build a {@link PageResolver} from a manifest. A destination href resolves to + * the page owning the longest matching path prefix; if that is the *current* + * page, the resolver returns `null` so the navigation stays in-page. + */ +export function createManifestPageResolver(manifest: PageManifest): PageResolver { + const pageOf = (href: string): PageManifestEntry | undefined => { + const pathname = pathnameOf(href); + let best: PageManifestEntry | undefined; + let bestLen = 0; + for (const page of manifest.pages) { + for (const prefix of page.paths) { + const len = matchLen(pathname, prefix); + if (len > bestLen) { + bestLen = len; + best = page; + } + } + } + return best; + }; + + return (href, ctx) => { + const destPage = pageOf(href); + const currentPage = pageOf(ctx.currentHref); + if (!destPage) return null; + // Same page → in-page transition. + if (currentPage && destPage.id === currentPage.id) return null; + const target: PageTarget = { id: destPage.id }; + 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 new file mode 100644 index 00000000..bbaa57dc --- /dev/null +++ b/packages/sparkling-history/src/types.ts @@ -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. + +/** + * A parsed location. Structurally identical to `HistoryLocation` from + * `@tanstack/history` so an `MpaHistory` can be handed to TanStack Router + * directly, but declared locally so this package has zero dependencies and + * can back other routers (React Router, custom) through the same contract. + */ +export interface HistoryLocation { + href: string; + pathname: string; + search: string; + hash: string; + state: ParsedHistoryState; +} + +/** + * Entry state. `__TSR_index` is the position of the entry in the logical + * history stack. In an MPA world this index is *global across native pages*: + * it is seeded with the native stack depth of the page (see + * `NavigationHost.getStackDepth`), so `canGoBack()` (and TanStack's + * `useCanGoBack`) stays true on a pushed page even when the local in-page + * stack is at its root. + */ +export type ParsedHistoryState = { + key?: string; + __TSR_key?: string; + __TSR_index: number; +} & Record; + +export type HistoryAction = 'PUSH' | 'REPLACE' | 'FORWARD' | 'BACK' | 'GO'; + +export type SubscriberHistoryAction = + | { type: Exclude } + | { type: 'GO'; index: number }; + +export interface SubscriberArgs { + location: HistoryLocation; + action: SubscriberHistoryAction; +} + +export interface NavigateOptions { + ignoreBlocker?: boolean; +} + +export type BlockerFnArgs = { + currentLocation: HistoryLocation; + nextLocation: HistoryLocation; + action: HistoryAction; +}; + +/** Return truthy to block the navigation. */ +export type BlockerFn = (args: BlockerFnArgs) => Promise | boolean; + +export type NavigationBlocker = { + blockerFn: BlockerFn; + /** Web-only concept; accepted for API compatibility, unused on Lynx. */ + enableBeforeUnload?: (() => boolean) | boolean; +}; + +/** + * The history object produced by `createMpaHistory`. + * + * Structurally compatible with `RouterHistory` from `@tanstack/history` + * (same members, same semantics for the single-page subset), with one + * deliberate difference: navigation blockers run in any JS environment, + * not only when a global `document` exists. + */ +export interface MpaHistory { + readonly location: HistoryLocation; + readonly length: number; + subscribers: Set<(opts: SubscriberArgs) => void>; + subscribe: (cb: (opts: SubscriberArgs) => void) => () => void; + push: (path: string, state?: unknown, navigateOpts?: NavigateOptions) => void; + replace: (path: string, state?: unknown, navigateOpts?: NavigateOptions) => void; + go: (index: number, navigateOpts?: NavigateOptions) => void; + back: (navigateOpts?: NavigateOptions) => void; + forward: (navigateOpts?: NavigateOptions) => void; + canGoBack: () => boolean; + createHref: (href: string) => string; + block: (blocker: NavigationBlocker) => () => void; + flush: () => void; + destroy: () => void; + notify: (action: SubscriberHistoryAction) => void; + _ignoreSubscribers?: boolean; +} + +// --------------------------------------------------------------------------- +// NavigationHost — the API contract a container platform implements +// --------------------------------------------------------------------------- + +/** + * A page (routing subtree) that lives in its own container / JS context. + * `id` conventionally maps to a bundle name (`.lynx.bundle` on + * sparkling), but the host decides how to interpret it. + */ +export interface PageTarget { + id: string; + /** + * Static container configuration resolved *before* the target page's JS + * boots (title, nav bar, orientation, ... — sparkling scheme params). + */ + containerParams?: Record; +} + +export interface HostOpenTarget { + /** App-relative destination href: `pathname?search#hash`. */ + href: string; + /** The resolved destination page. */ + page: PageTarget; + /** Replace the current page instead of pushing a new one. */ + replace: boolean; + /** + * JSON-serializable navigation state to hand to the destination page + * (delivered as its initial `location.state`). Hosts transport it out of + * band of the href (e.g. a scheme query param). + */ + state?: unknown; +} + +export interface HostCloseOptions { + animated?: boolean; +} + +export interface HostNavigationResult { + ok: boolean; + message?: string; +} + +/** + * The contract between the history shim and a native multi-page container. + * + * Anything that can (1) report the URL and stack depth it was launched + * with, (2) open a new page for an href, and (3) close itself, can host a + * URL-driven router. sparkling-navigation is one implementation; a plain + * browser window or a test double are others. + */ +export interface NavigationHost { + /** The app-relative href (`pathname?search#hash`) this page was launched with. */ + getInitialHref(): string; + /** + * Depth of this page in the native stack (0 = root page). Used to seed + * `__TSR_index` so back-affordances work across page boundaries. + */ + getStackDepth?(): number; + /** Initial navigation state handed over by the opener, if any. */ + getInitialState?(): unknown; + /** Ask the container to open (push or replace) another page. */ + open(target: HostOpenTarget): void | Promise; + /** Ask the container to close/pop the current page. */ + close(opts?: HostCloseOptions): void | Promise; +} + +// --------------------------------------------------------------------------- +// Page resolution +// --------------------------------------------------------------------------- + +/** + * Decides whether a destination href belongs to another page (returning its + * `PageTarget`) or to the current page (returning `null`, letting the + * navigation stay in-page as a plain SPA transition). + */ +export type PageResolver = ( + href: string, + ctx: { currentHref: string }, +) => PageTarget | null; + +export interface CreateMpaHistoryOptions { + host: NavigationHost; + /** + * Cross-page decision function. Defaults to `() => null` (everything is + * in-page — degenerates to a memory history seeded from the host). + */ + resolvePage?: PageResolver; + /** Override the initial href reported by the host. */ + initialHref?: string; + /** Called when the host rejects an open/close request. */ + onHostError?: (error: unknown) => void; +} diff --git a/packages/sparkling-history/tests/in-page.test.ts b/packages/sparkling-history/tests/in-page.test.ts new file mode 100644 index 00000000..9eb63a12 --- /dev/null +++ b/packages/sparkling-history/tests/in-page.test.ts @@ -0,0 +1,145 @@ +// 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. +// +// In-page (single-page) semantics of createMpaHistory. These are ported from +// @tanstack/history's createMemoryHistory tests to prove that, when every +// destination stays in-page (no page resolver), the shim behaves exactly like +// TanStack's memory history — the property that makes it a drop-in. +import { describe, expect, test, vi } from 'vitest'; +import { createMpaHistory } from '../src/create-mpa-history.js'; +import { createMemoryHost } from '../src/hosts/memory.js'; + +function memHistory(initialHref = '/') { + // No resolvePage → all navigations stay in-page. + return createMpaHistory({ host: createMemoryHost({ initialHref }) }); +} + +describe('createMpaHistory — in-page parity with memory history', () => { + test('back', () => { + const history = memHistory('/initial'); + 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('/initial'); + }); + + test('forward', () => { + const history = memHistory(); + 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 = memHistory(); + history.push('/a'); + expect(history.location.pathname).toBe('/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 = memHistory(); + expect(history.length).toBe(1); + history.push('/a'); + expect(history.length).toBe(2); + history.replace('/b'); + expect(history.length).toBe(2); + history.push('/c'); + expect(history.length).toBe(3); + }); + + test('state carried on push/replace', () => { + const history = memHistory(); + 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); + history.push('/c', { i: 3 }); + expect((history.location.state as { i?: number }).i).toBe(3); + }); + + test('__TSR_index increments/decrements', () => { + const history = memHistory(); + expect(history.location.state.__TSR_index).toBe(0); + history.push('/a'); + expect(history.location.state.__TSR_index).toBe(1); + history.push('/b'); + expect(history.location.state.__TSR_index).toBe(2); + history.back(); + expect(history.location.state.__TSR_index).toBe(1); + }); + + test('subscribers are notified with action', () => { + const history = memHistory(); + const sub = vi.fn(); + const unsub = history.subscribe(sub); + history.push('/a'); + expect(sub).toHaveBeenCalledWith( + expect.objectContaining({ action: { type: 'PUSH' } }), + ); + unsub(); + history.push('/b'); + expect(sub).toHaveBeenCalledTimes(1); + }); + + test('block prevents navigation', async () => { + const history = memHistory(); + const blockerFn = vi.fn(() => true); + const unblock = history.block({ blockerFn, enableBeforeUnload: false }); + await history.push('/a'); + expect(history.location.pathname).toBe('/'); + expect(blockerFn).toHaveBeenCalled(); + unblock(); + }); + + test('block allows navigation when blockerFn returns false', async () => { + const history = memHistory(); + const blockerFn = vi.fn(() => false); + const unblock = history.block({ blockerFn, enableBeforeUnload: false }); + await history.push('/a'); + expect(history.location.pathname).toBe('/a'); + expect(blockerFn).toHaveBeenCalled(); + unblock(); + }); + + test('unblock removes blocker', async () => { + const history = memHistory(); + const blockerFn = vi.fn(() => true); + const unblock = history.block({ blockerFn, enableBeforeUnload: false }); + unblock(); + await history.push('/a'); + expect(history.location.pathname).toBe('/a'); + expect(blockerFn).not.toHaveBeenCalled(); + }); + + test('ignoreBlocker bypasses blockers (unlike @tanstack/history, works with no document)', async () => { + const history = memHistory(); + const blockerFn = vi.fn(() => true); + history.block({ blockerFn, enableBeforeUnload: false }); + await history.push('/a', undefined, { ignoreBlocker: true }); + expect(history.location.pathname).toBe('/a'); + expect(blockerFn).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/sparkling-history/tests/mpa.test.ts b/packages/sparkling-history/tests/mpa.test.ts new file mode 100644 index 00000000..8f3e3bc9 --- /dev/null +++ b/packages/sparkling-history/tests/mpa.test.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. +// +// MPA-specific behavior: the boundary between in-page SPA navigation and +// native page opens, seeded stack depth, and cross-page back → close. +import { describe, expect, test } from 'vitest'; +import { createMpaHistory } from '../src/create-mpa-history.js'; +import { createMemoryHost } from '../src/hosts/memory.js'; +import { createManifestPageResolver, type PageManifest } from '../src/resolve-page.js'; + +const manifest: PageManifest = { + pages: [ + { id: 'main', paths: ['/'] }, + { id: 'detail', paths: ['/detail'], containerParams: { title: 'Detail' } }, + { id: 'settings', paths: ['/settings'] }, + ], +}; + +describe('createMpaHistory — cross-page navigation', () => { + test('in-page push stays local; does NOT call host.open', () => { + const host = createMemoryHost({ initialHref: '/' }); + const resolvePage = createManifestPageResolver(manifest); + const history = createMpaHistory({ host, resolvePage }); + + // '/' and '/about-in-main' both belong to page 'main' + history.push('/nested'); + expect(host.opens).toHaveLength(0); + expect(history.location.pathname).toBe('/nested'); + }); + + test('cross-page push calls host.open and does NOT mutate local stack', () => { + const host = createMemoryHost({ initialHref: '/' }); + const resolvePage = createManifestPageResolver(manifest); + const history = createMpaHistory({ host, resolvePage }); + + history.push('/detail/42?ref=home'); + // Local stack untouched — the current page keeps rendering until the + // container covers it (document-navigation semantics). + expect(history.location.pathname).toBe('/'); + expect(host.opens).toHaveLength(1); + expect(host.opens[0]!.href).toBe('/detail/42?ref=home'); + expect(host.opens[0]!.page.id).toBe('detail'); + expect(host.opens[0]!.replace).toBe(false); + }); + + test('cross-page push forwards container params from the manifest', () => { + const host = createMemoryHost({ initialHref: '/' }); + const resolvePage = createManifestPageResolver(manifest); + const history = createMpaHistory({ host, resolvePage }); + history.push('/detail/42'); + expect(host.opens[0]!.page.containerParams).toEqual({ title: 'Detail' }); + }); + + test('cross-page push carries user state (minus internal keys)', () => { + const host = createMemoryHost({ initialHref: '/' }); + const resolvePage = createManifestPageResolver(manifest); + const history = createMpaHistory({ host, resolvePage }); + history.push('/settings', { tab: 'privacy' }); + expect(host.opens[0]!.state).toEqual({ tab: 'privacy' }); + }); + + test('cross-page replace maps to host.open({ replace: true })', () => { + const host = createMemoryHost({ initialHref: '/' }); + const resolvePage = createManifestPageResolver(manifest); + const history = createMpaHistory({ host, resolvePage }); + history.replace('/settings'); + expect(host.opens[0]!.replace).toBe(true); + }); + + test('back at local root → host.close (native pop)', () => { + const host = createMemoryHost({ initialHref: '/detail/42', stackDepth: 1 }); + const resolvePage = createManifestPageResolver(manifest); + const history = createMpaHistory({ host, resolvePage }); + history.back(); + expect(host.closes).toHaveLength(1); + }); + + test('back after in-page push pops locally, not the native page', () => { + const host = createMemoryHost({ initialHref: '/detail', stackDepth: 1 }); + const resolvePage = createManifestPageResolver(manifest); + const history = createMpaHistory({ host, resolvePage }); + history.push('/detail/sub'); // in-page (same page 'detail') + expect(history.location.pathname).toBe('/detail/sub'); + history.back(); + expect(history.location.pathname).toBe('/detail'); + expect(host.closes).toHaveLength(0); // did not pop the native page + }); + + test('stack depth seeds __TSR_index so canGoBack is true on a pushed page', () => { + const host = createMemoryHost({ initialHref: '/detail/42', stackDepth: 2 }); + const resolvePage = createManifestPageResolver(manifest); + const history = createMpaHistory({ host, resolvePage }); + // Even at the local root, we are 2 pages deep natively. + expect(history.location.state.__TSR_index).toBe(2); + expect(history.canGoBack()).toBe(true); + }); + + test('root page: canGoBack is false at depth 0', () => { + const host = createMemoryHost({ initialHref: '/', stackDepth: 0 }); + const resolvePage = createManifestPageResolver(manifest); + const history = createMpaHistory({ host, resolvePage }); + expect(history.canGoBack()).toBe(false); + }); + + test('initial location reconstructed from host initial href + state', () => { + const host = createMemoryHost({ + initialHref: '/detail/42?ref=home', + stackDepth: 1, + initialState: { scrollTo: 100 }, + }); + const resolvePage = createManifestPageResolver(manifest); + const history = createMpaHistory({ host, resolvePage }); + expect(history.location.pathname).toBe('/detail/42'); + expect(history.location.search).toBe('?ref=home'); + expect((history.location.state as { scrollTo?: number }).scrollTo).toBe(100); + }); + + test('onHostError fires when host.open rejects', async () => { + let captured: unknown; + const host = createMemoryHost({ initialHref: '/' }); + // Override open to reject. + host.open = () => Promise.resolve({ ok: false, message: 'boom' }); + const resolvePage = createManifestPageResolver(manifest); + const history = createMpaHistory({ + host, + resolvePage, + onHostError: (e) => { + captured = e; + }, + }); + history.push('/detail'); + await new Promise((r) => setTimeout(r, 0)); + expect(captured).toBeInstanceOf(Error); + }); +}); diff --git a/packages/sparkling-history/tests/sparkling-host.test.ts b/packages/sparkling-history/tests/sparkling-host.test.ts new file mode 100644 index 00000000..124dd3fe --- /dev/null +++ b/packages/sparkling-history/tests/sparkling-host.test.ts @@ -0,0 +1,144 @@ +// 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. +// +// The sparkling host adapter: scheme building, param transport, and +// round-tripping the launch query back into an initial location. +import { describe, expect, test, vi } from 'vitest'; +import { createSparklingHost } from '../src/hosts/sparkling.js'; +import { createMpaHistory } from '../src/create-mpa-history.js'; +import { createManifestPageResolver, type PageManifest } from '../src/resolve-page.js'; + +const manifest: PageManifest = { + pages: [ + { id: 'main', paths: ['/'] }, + { id: 'detail', paths: ['/detail'], containerParams: { title: 'Detail', hide_nav_bar: '1' } }, + ], +}; + +function fakeNavigation() { + const openCalls: Array<{ scheme: string; options?: Record }> = []; + const closeCalls: Array<{ animated?: boolean } | undefined> = []; + return { + openCalls, + closeCalls, + open( + params: { scheme: string; options?: Record }, + cb: (r: { code: number; msg: string }) => void, + ) { + openCalls.push(params); + cb({ code: 1, msg: 'ok' }); + }, + close(params: { animated?: boolean } | undefined, cb: (r: { code: number; msg: string }) => void) { + closeCalls.push(params); + cb({ code: 1, msg: 'ok' }); + }, + }; +} + +describe('createSparklingHost', () => { + test('open builds a hybrid scheme with bundle + transport params', () => { + const navigation = fakeNavigation(); + const host = createSparklingHost({ navigation, getQueryItems: () => ({}) }); + const resolvePage = createManifestPageResolver(manifest); + const history = createMpaHistory({ host, resolvePage }); + + history.push('/detail/42?ref=home', { scrollTo: 10 }); + + expect(navigation.openCalls).toHaveLength(1); + const url = new URL(navigation.openCalls[0]!.scheme); + expect(url.protocol).toBe('hybrid:'); + expect(url.searchParams.get('bundle')).toBe('detail.lynx.bundle'); + // container params from manifest + expect(url.searchParams.get('title')).toBe('Detail'); + expect(url.searchParams.get('hide_nav_bar')).toBe('1'); + // MPA transport + expect(url.searchParams.get('__mpa_href')).toBe('/detail/42?ref=home'); + expect(url.searchParams.get('__mpa_depth')).toBe('1'); + expect(JSON.parse(url.searchParams.get('__mpa_state')!)).toEqual({ scrollTo: 10 }); + }); + + test('replace passes options.replace to sparkling open (animated by default)', () => { + const navigation = fakeNavigation(); + const host = createSparklingHost({ navigation, getQueryItems: () => ({}) }); + const resolvePage = createManifestPageResolver(manifest); + const history = createMpaHistory({ host, resolvePage }); + history.replace('/detail'); + expect(navigation.openCalls[0]!.options).toEqual({ replace: true, animated: true }); + }); + + test('animated: false opts out of the native container transition', () => { + const navigation = fakeNavigation(); + const host = createSparklingHost({ + navigation, + getQueryItems: () => ({}), + animated: false, + }); + const resolvePage = createManifestPageResolver(manifest); + const history = createMpaHistory({ host, resolvePage }); + history.push('/detail'); + expect(navigation.openCalls[0]!.options).toEqual({ replace: false, animated: false }); + }); + + test('back at root calls sparkling close (animated by default)', () => { + const navigation = fakeNavigation(); + const host = createSparklingHost({ + navigation, + getQueryItems: () => ({ __mpa_href: '/detail', __mpa_depth: '1' }), + }); + const resolvePage = createManifestPageResolver(manifest); + const history = createMpaHistory({ host, resolvePage }); + history.back(); + expect(navigation.closeCalls).toHaveLength(1); + expect(navigation.closeCalls[0]).toEqual({ animated: true }); + }); + + test('reconstructs initial href/depth/state from query items (round-trip)', () => { + const navigation = fakeNavigation(); + // Simulate the queryItems a page was launched with. + const host = createSparklingHost({ + navigation, + getQueryItems: () => ({ + __mpa_href: '/detail/42?ref=home', + __mpa_depth: '3', + __mpa_state: JSON.stringify({ scrollTo: 50 }), + }), + }); + const history = createMpaHistory({ host, resolvePage: createManifestPageResolver(manifest) }); + + expect(history.location.pathname).toBe('/detail/42'); + expect(history.location.search).toBe('?ref=home'); + expect(history.location.state.__TSR_index).toBe(3); + expect((history.location.state as { scrollTo?: number }).scrollTo).toBe(50); + }); + + test('spaces in scheme are encoded as %20, not +', () => { + const navigation = fakeNavigation(); + const host = createSparklingHost({ navigation, getQueryItems: () => ({}) }); + const history = createMpaHistory({ host, resolvePage: createManifestPageResolver(manifest) }); + history.push('/detail?q=hello world'); + expect(navigation.openCalls[0]!.scheme).not.toContain('+'); + expect(navigation.openCalls[0]!.scheme).toContain('%20'); + }); + + test('open failure (code !== 1) surfaces through onHostError', async () => { + const onHostError = vi.fn(); + const navigation = { + open(_p: unknown, cb: (r: { code: number; msg: string }) => void) { + cb({ code: 0, msg: 'router unavailable' }); + }, + close(_p: unknown, cb: (r: { code: number; msg: string }) => void) { + cb({ code: 1, msg: 'ok' }); + }, + }; + const host = createSparklingHost({ navigation, getQueryItems: () => ({}) }); + const history = createMpaHistory({ + host, + resolvePage: createManifestPageResolver(manifest), + onHostError, + }); + history.push('/detail'); + await new Promise((r) => setTimeout(r, 0)); + expect(onHostError).toHaveBeenCalled(); + }); +}); diff --git a/packages/sparkling-history/tsconfig.json b/packages/sparkling-history/tsconfig.json new file mode 100644 index 00000000..2037df8e --- /dev/null +++ b/packages/sparkling-history/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "declaration": true, + "outDir": "dist", + "strict": true, + "skipLibCheck": true, + "isolatedModules": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts", "tests"] +} diff --git a/packages/sparkling-history/vitest.config.ts b/packages/sparkling-history/vitest.config.ts new file mode 100644 index 00000000..6a8d4a71 --- /dev/null +++ b/packages/sparkling-history/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + // Pure logic — no DOM needed. This is deliberately a non-jsdom suite: + // one of the shim's guarantees is that navigation blocking works with + // no global `document`, which @tanstack/history cannot claim. + environment: 'node', + include: ['tests/**/*.test.ts'], + }, +}); diff --git a/packages/sparkling-router-plugin/README.md b/packages/sparkling-router-plugin/README.md new file mode 100644 index 00000000..d85a6b06 --- /dev/null +++ b/packages/sparkling-router-plugin/README.md @@ -0,0 +1,30 @@ +# Sparkling Router Plugin + +This package prototypes two authoring frontends over one serializable route +manifest: + +- `tanstack`: `src/routes/**` with `createFileRoute` and `_container.tsx` + boundaries; +- `next`: `app/**/page.tsx` with optional `container.ts` boundaries. + +Both conventions compile to the same `RouteManifest` consumed by +`sparkling-router`. Components and loaders are intentionally absent from the +manifest so every native bundle can receive the same versioned routing data. + +For TanStack projects, the intended pipeline is: + +1. keep `@tanstack/router-generator` for `routeTree.gen.ts` and typed routes; +2. run this compiler for container partitioning, the global manifest, and + Rspeedy entry metadata. + +```sh +sparkling-router \ + --convention tanstack \ + --routes src/routes \ + --out src/routes.manifest.json +``` + +The Next frontend is a compatibility spike, not a Next.js runtime. It proves +that app-directory paths and layouts can target the same core/schema without +changing `sparkling-router`. React-facing `next/link` and `useRouter` shims are +future binding work. diff --git a/packages/sparkling-router-plugin/package.json b/packages/sparkling-router-plugin/package.json new file mode 100644 index 00000000..3be47f27 --- /dev/null +++ b/packages/sparkling-router-plugin/package.json @@ -0,0 +1,40 @@ +{ + "name": "sparkling-router-plugin", + "version": "2.1.0-rc.12", + "description": "File-route compiler for Sparkling Router with TanStack and Next.js authoring frontends", + "homepage": "https://tiktok.github.io/sparkling/", + "repository": { + "type": "git", + "url": "https://github.com/tiktok/sparkling", + "directory": "packages/sparkling-router-plugin" + }, + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "bin": { + "sparkling-router": "./dist/cli.js" + }, + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "tsc", + "test": "vitest run" + }, + "dependencies": { + "sparkling-router": "workspace:*", + "typescript": "^5.8.3" + }, + "devDependencies": { + "@types/node": "^22.15.17", + "vitest": "^3.2.4" + }, + "license": "Apache-2.0" +} diff --git a/packages/sparkling-router-plugin/src/cli.ts b/packages/sparkling-router-plugin/src/cli.ts new file mode 100644 index 00000000..8e09a644 --- /dev/null +++ b/packages/sparkling-router-plugin/src/cli.ts @@ -0,0 +1,25 @@ +#!/usr/bin/env node +import { writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { compileRoutes } from './compiler.js'; +import type { AuthoringConvention } from './types.js'; + +const args = process.argv.slice(2); +const value = (flag: string) => { + const index = args.indexOf(flag); + return index === -1 ? undefined : args[index + 1]; +}; +const convention = (value('--convention') ?? 'tanstack') as AuthoringConvention; +if (convention !== 'tanstack' && convention !== 'next') { + throw new Error('--convention must be "tanstack" or "next"'); +} +const routesDirectory = resolve(value('--routes') ?? 'src/routes'); +const output = resolve(value('--out') ?? 'src/routes.manifest.json'); +const result = compileRoutes({ convention, routesDirectory }); +writeFileSync( + output, + `${JSON.stringify({ manifest: result.manifest, entries: result.entries }, null, 2)}\n`, +); +console.log( + `sparkling-router: ${result.routes.length} routes, ${result.manifest.containers.length} containers -> ${output}`, +); diff --git a/packages/sparkling-router-plugin/src/compiler.ts b/packages/sparkling-router-plugin/src/compiler.ts new file mode 100644 index 00000000..3079396d --- /dev/null +++ b/packages/sparkling-router-plugin/src/compiler.ts @@ -0,0 +1,68 @@ +import { relative } from 'node:path'; +import type { RouteContainer, RouteManifest } from 'sparkling-router'; +import { scanNextRoutes, scanTanstackRoutes } from './scan.js'; +import type { CompileRoutesOptions, CompileRoutesResult, RouteSource } from './types.js'; + +function compileManifest( + routes: RouteSource[], + options: CompileRoutesOptions, +): RouteManifest { + const containers = new Map(); + for (const route of routes) { + const current = containers.get(route.containerId); + if (current) { + if (current.presentation !== route.presentation) { + throw new Error( + `Container "${route.containerId}" mixes ${current.presentation} and ${route.presentation} presentation`, + ); + } + current.routes.push({ path: route.path }); + continue; + } + containers.set(route.containerId, { + id: route.containerId, + bundle: `${route.containerId}.lynx.bundle`, + presentation: route.presentation, + routes: [{ path: route.path }], + containerOptions: route.containerOptions, + }); + } + + return { + version: options.version ?? 'prototype-1', + scheme: { base: options.schemeBase ?? 'hybrid://lynxview_page' }, + containers: [...containers.values()] + .map((container) => ({ + ...container, + routes: container.routes.sort((left, right) => left.path.localeCompare(right.path)), + })) + .sort((left, right) => left.id.localeCompare(right.id)), + }; +} + +export function compileRoutes(options: CompileRoutesOptions): CompileRoutesResult { + const routes = + options.convention === 'tanstack' + ? scanTanstackRoutes(options) + : scanNextRoutes(options); + if (routes.length === 0) { + throw new Error(`No ${options.convention} routes found in ${options.routesDirectory}`); + } + const manifest = compileManifest(routes, options); + const entries = Object.fromEntries( + manifest.containers.map((container) => { + const source = routes.find((route) => route.containerId === container.id)!; + return [container.id, relative(process.cwd(), source.file)]; + }), + ); + return { + manifest, + entries, + routes, + diagnostics: [ + options.convention === 'tanstack' + ? 'Use @tanstack/router-generator for routeTree.gen.ts; Sparkling only adds container partitioning.' + : 'Next app-dir files compile to the same neutral manifest; React bindings remain a separate frontend.', + ], + }; +} diff --git a/packages/sparkling-router-plugin/src/index.ts b/packages/sparkling-router-plugin/src/index.ts new file mode 100644 index 00000000..3f6d8ba5 --- /dev/null +++ b/packages/sparkling-router-plugin/src/index.ts @@ -0,0 +1,8 @@ +export { compileRoutes } from './compiler.js'; +export { scanNextRoutes, scanTanstackRoutes } from './scan.js'; +export type { + AuthoringConvention, + CompileRoutesOptions, + CompileRoutesResult, + RouteSource, +} from './types.js'; diff --git a/packages/sparkling-router-plugin/src/scan.ts b/packages/sparkling-router-plugin/src/scan.ts new file mode 100644 index 00000000..220c1baf --- /dev/null +++ b/packages/sparkling-router-plugin/src/scan.ts @@ -0,0 +1,138 @@ +import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { basename, dirname, extname, join, relative, sep } from 'node:path'; +import { readCreateFileRoutePath, readExportedObject } from './static-config.js'; +import type { CompileRoutesOptions, RouteSource } from './types.js'; + +const ROUTE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx']); + +function walk(directory: string): string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = join(directory, entry.name); + return entry.isDirectory() ? walk(path) : [path]; + }); +} + +function toPosix(path: string): string { + return path.split(sep).join('/'); +} + +function normalizePath(path: string): string { + const normalized = `/${path}`.replace(/\/+/g, '/').replace(/\/$/, ''); + return normalized || '/'; +} + +function tanstackPathFromFile(routesDirectory: string, file: string): string { + const source = readFileSync(file, 'utf8'); + const explicitPath = readCreateFileRoutePath(source, file); + if (explicitPath) return explicitPath; + const routeFile = toPosix(relative(routesDirectory, file)).replace(/\.(tsx?|jsx?)$/, ''); + const segments = routeFile + .split('/') + .flatMap((segment) => segment.split('.')) + .filter((segment) => segment !== 'index' && !segment.startsWith('_')); + return normalizePath(segments.join('/')); +} + +function findTanstackBoundary(routesDirectory: string, file: string): string | undefined { + let directory = dirname(file); + while (directory.startsWith(routesDirectory)) { + for (const extension of ROUTE_EXTENSIONS) { + const candidate = join(directory, `_container${extension}`); + if (existsSync(candidate)) return candidate; + const modalCandidate = join(directory, `_container.modal${extension}`); + if (existsSync(modalCandidate)) return modalCandidate; + } + if (directory === routesDirectory) break; + directory = dirname(directory); + } + return undefined; +} + +function defaultContainerId(path: string): string { + return path.split('/').filter(Boolean)[0] ?? 'root'; +} + +export function scanTanstackRoutes(options: CompileRoutesOptions): RouteSource[] { + return walk(options.routesDirectory) + .filter((file) => ROUTE_EXTENSIONS.has(extname(file))) + .filter((file) => !basename(file).startsWith('__root')) + .filter((file) => !basename(file).startsWith('_container')) + .map((file) => { + const path = tanstackPathFromFile(options.routesDirectory, file); + const boundary = findTanstackBoundary(options.routesDirectory, file); + const config = boundary + ? readExportedObject(readFileSync(boundary, 'utf8'), boundary, 'container') + : readExportedObject(readFileSync(file, 'utf8'), file, 'container'); + const inferredModal = boundary ? basename(boundary).includes('.modal.') : false; + const containerId = config?.id ?? defaultContainerId(path); + return { + file, + path, + containerId, + presentation: config?.presentation ?? (inferredModal ? 'modal' : 'push'), + containerOptions: config?.containerOptions, + }; + }); +} + +function nextRoutePath(routesDirectory: string, file: string): string { + const directory = toPosix(relative(routesDirectory, dirname(file))); + const segments = directory + .split('/') + .filter(Boolean) + .filter((segment) => !(segment.startsWith('(') && segment.endsWith(')'))) + .map((segment) => { + const dynamic = segment.match(/^\[(?:\.\.\.)?(.+)]$/); + return dynamic ? `:${dynamic[1]}` : segment; + }); + return normalizePath(segments.join('/')); +} + +function nearestNextContainer(routesDirectory: string, file: string): { + file?: string; + id: string; + presentation: 'push' | 'modal'; + containerOptions?: Record; +} { + let directory = dirname(file); + while (directory.startsWith(routesDirectory)) { + for (const extension of ROUTE_EXTENSIONS) { + const candidate = join(directory, `container${extension}`); + if (!existsSync(candidate)) continue; + const config = readExportedObject( + readFileSync(candidate, 'utf8'), + candidate, + 'container', + ); + const segment = basename(directory); + return { + file: candidate, + id: config?.id ?? (segment === basename(routesDirectory) ? 'root' : segment), + presentation: config?.presentation ?? 'push', + containerOptions: config?.containerOptions, + }; + } + if (directory === routesDirectory) break; + directory = dirname(directory); + } + const path = nextRoutePath(routesDirectory, file); + return { + id: defaultContainerId(path), + presentation: 'push', + }; +} + +export function scanNextRoutes(options: CompileRoutesOptions): RouteSource[] { + return walk(options.routesDirectory) + .filter((file) => /^page\.(tsx?|jsx?)$/.test(basename(file))) + .map((file) => { + const boundary = nearestNextContainer(options.routesDirectory, file); + return { + file, + path: nextRoutePath(options.routesDirectory, file), + containerId: boundary.id, + presentation: boundary.presentation, + containerOptions: boundary.containerOptions, + }; + }); +} diff --git a/packages/sparkling-router-plugin/src/static-config.ts b/packages/sparkling-router-plugin/src/static-config.ts new file mode 100644 index 00000000..b7c8c9fb --- /dev/null +++ b/packages/sparkling-router-plugin/src/static-config.ts @@ -0,0 +1,94 @@ +import ts from 'typescript'; + +export interface StaticContainerConfig { + id?: string; + presentation?: 'push' | 'modal'; + containerOptions?: Record; +} + +function propertyName(node: ts.PropertyName): string | undefined { + if (ts.isIdentifier(node) || ts.isStringLiteral(node)) { + return node.text; + } + return undefined; +} + +function literalValue(node: ts.Expression): unknown { + if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) { + return node.text; + } + if (node.kind === ts.SyntaxKind.TrueKeyword) return true; + if (node.kind === ts.SyntaxKind.FalseKeyword) return false; + if (ts.isObjectLiteralExpression(node)) { + return Object.fromEntries( + node.properties.flatMap((property) => { + if (!ts.isPropertyAssignment(property)) return []; + const name = propertyName(property.name); + if (!name) return []; + return [[name, literalValue(property.initializer)]]; + }), + ); + } + return undefined; +} + +export function readExportedObject( + sourceText: string, + fileName: string, + exportName: string, +): StaticContainerConfig | undefined { + const sourceFile = ts.createSourceFile( + fileName, + sourceText, + ts.ScriptTarget.Latest, + true, + fileName.endsWith('x') ? ts.ScriptKind.TSX : ts.ScriptKind.TS, + ); + for (const statement of sourceFile.statements) { + if (!ts.isVariableStatement(statement)) continue; + const exported = statement.modifiers?.some( + (modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword, + ); + if (!exported) continue; + for (const declaration of statement.declarationList.declarations) { + if ( + ts.isIdentifier(declaration.name) && + declaration.name.text === exportName && + declaration.initializer && + ts.isObjectLiteralExpression(declaration.initializer) + ) { + return literalValue(declaration.initializer) as StaticContainerConfig; + } + } + } + return undefined; +} + +export function readCreateFileRoutePath( + sourceText: string, + fileName: string, +): string | undefined { + const sourceFile = ts.createSourceFile( + fileName, + sourceText, + ts.ScriptTarget.Latest, + true, + fileName.endsWith('x') ? ts.ScriptKind.TSX : ts.ScriptKind.TS, + ); + let routePath: string | undefined; + const visit = (node: ts.Node) => { + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === 'createFileRoute' + ) { + const argument = node.arguments[0]; + if (argument && ts.isStringLiteral(argument)) { + routePath = argument.text; + } + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + return routePath; +} diff --git a/packages/sparkling-router-plugin/src/types.ts b/packages/sparkling-router-plugin/src/types.ts new file mode 100644 index 00000000..11c446bb --- /dev/null +++ b/packages/sparkling-router-plugin/src/types.ts @@ -0,0 +1,25 @@ +import type { RouteManifest } from 'sparkling-router'; + +export type AuthoringConvention = 'tanstack' | 'next'; + +export interface CompileRoutesOptions { + convention: AuthoringConvention; + routesDirectory: string; + version?: string; + schemeBase?: string; +} + +export interface RouteSource { + file: string; + path: string; + containerId: string; + presentation: 'push' | 'modal'; + containerOptions?: Record; +} + +export interface CompileRoutesResult { + manifest: RouteManifest; + entries: Record; + routes: RouteSource[]; + diagnostics: string[]; +} diff --git a/packages/sparkling-router-plugin/tests/compiler.test.ts b/packages/sparkling-router-plugin/tests/compiler.test.ts new file mode 100644 index 00000000..175f024a --- /dev/null +++ b/packages/sparkling-router-plugin/tests/compiler.test.ts @@ -0,0 +1,133 @@ +import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { describe, expect, test } from 'vitest'; +import { compileRoutes } from '../src/index.js'; + +function write(root: string, file: string, source: string) { + const target = join(root, file); + mkdirSync(dirname(target), { recursive: true }); + writeFileSync(target, source); +} + +function createTanstackFixture() { + const root = mkdtempSync(join(tmpdir(), 'sparkling-tanstack-')); + write(root, '__root.tsx', 'export const Route = {}'); + write( + root, + 'index.tsx', + "import { createFileRoute } from '@tanstack/react-router';\n" + + "export const container = { id: 'root' };\n" + + "export const Route = createFileRoute('/')({});\n", + ); + write( + root, + 'feed/_container.tsx', + "export const container = { id: 'feed', containerOptions: { title: 'Feed' } };\n", + ); + write( + root, + 'feed/index.tsx', + "export const Route = createFileRoute('/feed')({});\n", + ); + write( + root, + 'feed/$postId.tsx', + "export const Route = createFileRoute('/feed/$postId')({});\n", + ); + write( + root, + 'settings/_container.modal.tsx', + "export const container = { id: 'settings', presentation: 'modal' };\n", + ); + write( + root, + 'settings/index.tsx', + "export const Route = createFileRoute('/settings')({});\n", + ); + return root; +} + +function createNextFixture() { + const root = mkdtempSync(join(tmpdir(), 'sparkling-next-')); + write(root, 'container.ts', "export const container = { id: 'root' };\n"); + write(root, 'page.tsx', 'export default function Page() {}'); + write( + root, + 'feed/container.ts', + "export const container = { id: 'feed', containerOptions: { title: 'Feed' } };\n", + ); + write(root, 'feed/page.tsx', 'export default function Page() {}'); + write(root, 'feed/[postId]/page.tsx', 'export default function Page() {}'); + write( + root, + 'settings/container.ts', + "export const container = { id: 'settings', presentation: 'modal' };\n", + ); + write(root, 'settings/page.tsx', 'export default function Page() {}'); + return root; +} + +describe('sparkling-router-plugin', () => { + test('TanStack convention partitions _container subtrees', () => { + const result = compileRoutes({ + convention: 'tanstack', + routesDirectory: createTanstackFixture(), + version: 'test', + }); + expect(result.manifest.containers).toEqual([ + { + id: 'feed', + bundle: 'feed.lynx.bundle', + presentation: 'push', + routes: [{ path: '/feed' }, { path: '/feed/$postId' }], + containerOptions: { title: 'Feed' }, + }, + { + id: 'root', + bundle: 'root.lynx.bundle', + presentation: 'push', + routes: [{ path: '/' }], + containerOptions: undefined, + }, + { + id: 'settings', + bundle: 'settings.lynx.bundle', + presentation: 'modal', + routes: [{ path: '/settings' }], + containerOptions: undefined, + }, + ]); + expect(result.diagnostics[0]).toContain('@tanstack/router-generator'); + }); + + test('Next app-dir frontend emits the same neutral manifest', () => { + const tanstack = compileRoutes({ + convention: 'tanstack', + routesDirectory: createTanstackFixture(), + version: 'test', + }); + const next = compileRoutes({ + convention: 'next', + routesDirectory: createNextFixture(), + version: 'test', + }); + const normalizeDynamic = (value: unknown) => + JSON.parse(JSON.stringify(value).replaceAll(':postId', '$postId')); + expect(normalizeDynamic(next.manifest)).toEqual(tanstack.manifest); + expect(next.diagnostics[0]).toContain('same neutral manifest'); + }); + + test('static configuration is parsed without evaluating route source', () => { + const root = mkdtempSync(join(tmpdir(), 'sparkling-safe-')); + write( + root, + 'index.tsx', + "throw new Error('must not execute');\n" + + "export const container = { id: 'safe' };\n" + + "export const Route = createFileRoute('/')({});\n", + ); + const result = compileRoutes({ convention: 'tanstack', routesDirectory: root }); + expect(result.manifest.containers[0]?.id).toBe('safe'); + }); +}); diff --git a/packages/sparkling-router-plugin/tsconfig.json b/packages/sparkling-router-plugin/tsconfig.json new file mode 100644 index 00000000..edb9437d --- /dev/null +++ b/packages/sparkling-router-plugin/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "declaration": true, + "outDir": "dist", + "strict": true, + "skipLibCheck": true, + "isolatedModules": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src"], + "exclude": ["tests"] +} diff --git a/packages/sparkling-router-plugin/vitest.config.ts b/packages/sparkling-router-plugin/vitest.config.ts new file mode 100644 index 00000000..8363e164 --- /dev/null +++ b/packages/sparkling-router-plugin/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + include: ['tests/**/*.test.ts'], + }, +}); diff --git a/packages/sparkling-router/README.md b/packages/sparkling-router/README.md new file mode 100644 index 00000000..8b9757a6 --- /dev/null +++ b/packages/sparkling-router/README.md @@ -0,0 +1,42 @@ +# Sparkling Router + +`sparkling-router` is the framework-neutral runtime layer for URL-first +navigation across Sparkling containers. + +The prototype deliberately keeps `@tanstack/router-core` as its underlying +router and type system rather than copying that implementation. TanStack does +not publish framework-neutral `createRouter` / `createRoute` constructors from +`router-core`; those factories are supplied by framework bindings. ReactLynx +therefore uses the official `@tanstack/react-router` binding, connected to +Sparkling through the `RouterHistory` compatible implementation in +`sparkling-history`. + +```ts +import { + createManifestPageResolver, + createMpaHistory, + createRouter, +} from 'sparkling-router'; + +const router = createRouter({ + routeTree, + history: createMpaHistory({ + host, + resolvePage: createManifestPageResolver(manifest), + }), + isServer: false, + origin: 'http://sparkling.local', +}); +``` + +This proves that route matching, typed navigation, loaders, search validation, +and redirects can remain upstream TanStack concerns without maintaining a fork +of its core. Sparkling owns only: + +- the multi-container `RouterHistory`; +- the serializable route manifest; +- the native navigation host and stack protocol. + +ReactLynx rendering still uses `@tanstack/react-router` as a binding on top of +the same core. Native stack events, reset/prefetch/result, and Android/iOS +conformance remain outside this prototype. diff --git a/packages/sparkling-router/package.json b/packages/sparkling-router/package.json new file mode 100644 index 00000000..774b07c4 --- /dev/null +++ b/packages/sparkling-router/package.json @@ -0,0 +1,39 @@ +{ + "name": "sparkling-router", + "version": "2.1.0-rc.12", + "description": "URL-first declarative router core for Sparkling multi-container applications", + "homepage": "https://tiktok.github.io/sparkling/", + "repository": { + "type": "git", + "url": "https://github.com/tiktok/sparkling", + "directory": "packages/sparkling-router" + }, + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "tsc", + "test": "vitest run" + }, + "dependencies": { + "@tanstack/history": "1.162.0", + "@tanstack/react-router": "1.170.17", + "@tanstack/router-core": "1.171.14", + "sparkling-history": "workspace:*" + }, + "devDependencies": { + "typescript": "^5.8.3", + "vitest": "^3.2.4" + }, + "license": "Apache-2.0" +} diff --git a/packages/sparkling-router/src/index.ts b/packages/sparkling-router/src/index.ts new file mode 100644 index 00000000..e87bfd2a --- /dev/null +++ b/packages/sparkling-router/src/index.ts @@ -0,0 +1,26 @@ +export { + createRootRoute, + createRoute, + createRouter, + lazyRouteComponent, + Route, + Router, +} from '@tanstack/react-router'; +export { notFound, redirect, rootRouteId } from '@tanstack/router-core'; +export type { + AnyRoute, + AnyRouter, + NavigateOptions, + RegisteredRouter, + RouterOptions, +} from '@tanstack/router-core'; +export type { RouterHistory } from '@tanstack/history'; +export { createMpaHistory } from 'sparkling-history'; +export { createManifestPageResolver, resolveRoute } from './manifest.js'; +export type { + ContainerPresentation, + ResolvedRoute, + RouteContainer, + RouteManifest, + RoutePattern, +} from './types.js'; diff --git a/packages/sparkling-router/src/manifest.ts b/packages/sparkling-router/src/manifest.ts new file mode 100644 index 00000000..59819a04 --- /dev/null +++ b/packages/sparkling-router/src/manifest.ts @@ -0,0 +1,56 @@ +import type { ResolvedRoute, RouteManifest, RoutePattern } from './types.js'; + +function matchPattern(pattern: RoutePattern, pathname: string): Record | null { + const patternSegments = pattern.path.split('/').filter(Boolean); + const pathSegments = pathname.split('/').filter(Boolean); + + if (patternSegments.length !== pathSegments.length) { + return null; + } + + const params: Record = {}; + for (let index = 0; index < patternSegments.length; index++) { + const patternSegment = patternSegments[index]!; + const pathSegment = pathSegments[index]!; + if (patternSegment.startsWith('$') || patternSegment.startsWith(':')) { + params[patternSegment.slice(1)] = decodeURIComponent(pathSegment); + continue; + } + if (patternSegment !== pathSegment) { + return null; + } + } + return params; +} + +export function resolveRoute( + manifest: RouteManifest, + pathname: string, +): ResolvedRoute | undefined { + for (const container of manifest.containers) { + for (const route of container.routes) { + const params = matchPattern(route, pathname); + if (params) { + return { container, params }; + } + } + } + return undefined; +} + +export function createManifestPageResolver(manifest: RouteManifest) { + return (href: string, context: { currentHref: string }) => { + const current = resolveRoute(manifest, new URL(context.currentHref, 'sparkling://app').pathname); + const next = resolveRoute(manifest, new URL(href, 'sparkling://app').pathname); + if (!next || next.container.id === current?.container.id) { + return null; + } + return { + id: next.container.id, + containerParams: { + presentation: next.container.presentation, + ...next.container.containerOptions, + }, + }; + }; +} diff --git a/packages/sparkling-router/src/types.ts b/packages/sparkling-router/src/types.ts new file mode 100644 index 00000000..45eb7606 --- /dev/null +++ b/packages/sparkling-router/src/types.ts @@ -0,0 +1,26 @@ +export type ContainerPresentation = 'push' | 'modal'; + +export interface RoutePattern { + path: string; +} + +export interface RouteContainer { + id: string; + bundle: string; + presentation: ContainerPresentation; + routes: RoutePattern[]; + containerOptions?: Record; +} + +export interface RouteManifest { + version: string; + scheme: { + base: string; + }; + containers: RouteContainer[]; +} + +export interface ResolvedRoute { + container: RouteContainer; + params: Record; +} diff --git a/packages/sparkling-router/tests/router-core.test.ts b/packages/sparkling-router/tests/router-core.test.ts new file mode 100644 index 00000000..c0af02f3 --- /dev/null +++ b/packages/sparkling-router/tests/router-core.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, test } from 'vitest'; +import { + createManifestPageResolver, + createMpaHistory, + createRootRoute, + createRoute, + createRouter, + resolveRoute, + type RouteManifest, +} from '../src/index.js'; +import { createMemoryHost } from 'sparkling-history'; + +const manifest: RouteManifest = { + version: 'prototype-1', + scheme: { base: 'hybrid://lynxview_page' }, + containers: [ + { + id: 'home', + bundle: 'home.lynx.bundle', + presentation: 'push', + routes: [{ path: '/' }, { path: '/profile' }], + }, + { + id: 'feed', + bundle: 'feed.lynx.bundle', + presentation: 'push', + routes: [{ path: '/feed' }, { path: '/feed/$postId' }], + }, + ], +}; + +describe('sparkling-router over @tanstack/router-core', () => { + test('router-core works without @tanstack/react-router', async () => { + const root = createRootRoute(); + const index = createRoute({ getParentRoute: () => root, path: '/' }); + const profile = createRoute({ getParentRoute: () => root, path: '/profile' }); + const host = createMemoryHost({ initialHref: '/' }); + const router = createRouter({ + routeTree: root.addChildren([index, profile]), + history: createMpaHistory({ host }), + isServer: false, + origin: 'http://sparkling.local', + }); + + await router.load(); + await router.navigate({ to: '/profile' }); + expect(router.state.location.pathname).toBe('/profile'); + expect(host.opens).toHaveLength(0); + }); + + test('the same router-core forwards cross-container navigation', async () => { + const root = createRootRoute(); + const index = createRoute({ getParentRoute: () => root, path: '/' }); + const detail = createRoute({ getParentRoute: () => root, path: '/feed/$postId' }); + const host = createMemoryHost({ initialHref: '/' }); + const router = createRouter({ + routeTree: root.addChildren([index, detail]), + history: createMpaHistory({ + host, + resolvePage: createManifestPageResolver(manifest), + }), + isServer: false, + origin: 'http://sparkling.local', + }); + + await router.load(); + await router.navigate({ to: '/feed/$postId', params: { postId: '42' } }); + expect(router.state.location.pathname).toBe('/'); + expect(host.opens[0]?.page.id).toBe('feed'); + expect(host.opens[0]?.href).toBe('/feed/42'); + }); + + test('manifest resolution supports TanStack and Next dynamic segment syntax', () => { + expect(resolveRoute(manifest, '/feed/42')?.params).toEqual({ postId: '42' }); + const nextManifest: RouteManifest = { + ...manifest, + containers: [ + { + id: 'user', + bundle: 'user.lynx.bundle', + presentation: 'push', + routes: [{ path: '/user/:id' }], + }, + ], + }; + expect(resolveRoute(nextManifest, '/user/alice')?.params).toEqual({ id: 'alice' }); + }); +}); diff --git a/packages/sparkling-router/tsconfig.json b/packages/sparkling-router/tsconfig.json new file mode 100644 index 00000000..b38808fb --- /dev/null +++ b/packages/sparkling-router/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "declaration": true, + "outDir": "dist", + "strict": true, + "skipLibCheck": true, + "isolatedModules": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src"], + "exclude": ["tests"] +} diff --git a/packages/sparkling-router/vitest.config.ts b/packages/sparkling-router/vitest.config.ts new file mode 100644 index 00000000..8363e164 --- /dev/null +++ b/packages/sparkling-router/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + include: ['tests/**/*.test.ts'], + }, +}); diff --git a/packages/tanstack-router-demo/.gitignore b/packages/tanstack-router-demo/.gitignore new file mode 100644 index 00000000..5ceb299f --- /dev/null +++ b/packages/tanstack-router-demo/.gitignore @@ -0,0 +1,9 @@ +dist +node_modules +.rspeedy +# Generated by codegen (scripts/codegen.mjs) — committed for visibility, but +# regenerated on every build/test. If you prefer not to track them, uncomment: +# src/routeTree.gen.ts +# src/routes.manifest.ts +# src/page-entries.gen.ts +# src/pages.gen/ diff --git a/packages/tanstack-router-demo/README.md b/packages/tanstack-router-demo/README.md new file mode 100644 index 00000000..30fb0c28 --- /dev/null +++ b/packages/tanstack-router-demo/README.md @@ -0,0 +1,63 @@ +# tanstack-router-demo + +TanStack Router driving **sparkling-navigation** for native multi-page (MPA) +navigation, verified on the web harness. + +Unlike ReactLynx's existing SPA-in-a-single-LynxView story (TanStack/React +Router over a memory history inside one JS context), here **each page is a +separate LynxView with its own JS context**, and cross-page navigation is a +native container `open`. Pages are connected by pre-generated file-based +metadata (a route→page manifest), because they cannot share a JS heap. + +## Layout + +- `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/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/shims/` — the bundler-level shims that let TanStack Router run on + ReactLynx (see below). + +## What each navigation does + +| Action | Path change | Under the hood | +| --- | --- | --- | +| Home → Profile | `/` → `/profile` | in-page (same bundle) — memory-history transition, no native open | +| Home → Detail #42 | `/` → `/detail/42?ref=home` | cross-page — `host.open` → `router.open` → new LynxView | +| Detail #42 → #43 | `/detail/42` → `/detail/43` | in-page (same `detail` bundle) | +| Detail → Back | pop | `history.back()` at page root → `host.close` → native pop | + +Path params (`id`) and search params (`ref`) cross the JS-context boundary via +the sparkling scheme's query string and are read back from `queryItems`. + +## Running the web harness + +```bash +pnpm --filter tanstack-router-demo build:web +# serve the built bundles through the web shell: +LYNX_BUNDLE_DIR="$(pwd)/dist/web" pnpm --filter sparkling-web-shell dev +# open http://localhost:4200/?page=home +``` + +## Required shims (ReactLynx has no react-dom / DOM) + +All are bundler-level (`lynx.config.ts` `resolve.alias`) — no fork of TanStack +Router: + +- `react$` → `src/shims/react.ts`: adds `startTransition`/`useTransition` + (from `@lynx-js/react/compat`) and a `use` binding TanStack's dist links + against. +- `react-dom$` → `src/shims/react-dom.ts`: provides `flushSync` (the only + react-dom symbol in TanStack Router's client entry). +- `use-sync-external-store/shim*` → `@lynx-js/use-sync-external-store`. +- `src/shims/env.ts`: global polyfills (`scrollTo`, `AbortController`, + `queueMicrotask`) that router-core touches unconditionally. + +See `docs/en/guide/tanstack-router.md` for the full architecture and the +feature support matrix. diff --git a/packages/tanstack-router-demo/lynx.config.ts b/packages/tanstack-router-demo/lynx.config.ts new file mode 100644 index 00000000..87fcdd8a --- /dev/null +++ b/packages/tanstack-router-demo/lynx.config.ts @@ -0,0 +1,72 @@ +// 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'; +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'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + source: { + entry: { + // Feasibility spike (single page, memory history). + spike: './src/spike/index.tsx', + // MPA demo: generated page entries (home / detail / settings), each a + // separate bundle / JS context. + ...pageEntries, + }, + }, + resolve: { + alias: { + // Fill in React APIs missing from ReactLynx's `react` alias + // (startTransition, use) that TanStack Router's dist links against. + 'react$': path.resolve(__dirname, 'src/shims/react.ts'), + // TanStack Router's main entry imports { flushSync } from 'react-dom'. + // ReactLynx has no react-dom; provide the minimal shim. + 'react-dom$': path.resolve(__dirname, 'src/shims/react-dom.ts'), + // @tanstack/react-store pulls the uSES shim; Lynx ships its own build. + '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: 'asset:///', + filename: { + bundle: '[name].lynx.bundle', + }, + }, + environments: { + web: { + output: { + assetPrefix: '/', + distPath: { + root: 'dist/web', + }, + }, + }, + lynx: {}, + }, + tools: { + // Compose the OFFICIAL TanStack Router generator (generator-only mode, no + // code-splitting) into the Rspeedy/Rspack build. It regenerates + // routeTree.gen.ts from src/routes/* on build and watch, alongside + // pluginReactLynx. + rspack: (_config, { appendPlugins }) => { + appendPlugins( + TanStackRouterGeneratorRspack({ + target: 'react', + routesDirectory: './src/routes', + generatedRouteTree: './src/routeTree.gen.ts', + autoCodeSplitting: false, + }), + ); + }, + }, + plugins: [pluginReactLynx()], +}) diff --git a/packages/tanstack-router-demo/package.json b/packages/tanstack-router-demo/package.json new file mode 100644 index 00000000..aa1c9896 --- /dev/null +++ b/packages/tanstack-router-demo/package.json @@ -0,0 +1,43 @@ +{ + "name": "tanstack-router-demo", + "version": "2.1.0-rc.12", + "private": true, + "type": "module", + "description": "TanStack Router driving sparkling-navigation (MPA) demo & verification app", + "homepage": "https://tiktok.github.io/sparkling/", + "repository": { + "type": "git", + "url": "https://github.com/tiktok/sparkling", + "directory": "packages/tanstack-router-demo" + }, + "scripts": { + "codegen": "node scripts/codegen.mjs", + "build": "pnpm codegen && rspeedy build", + "build:web": "pnpm codegen && rspeedy build --environment web", + "dev": "pnpm codegen && rspeedy dev", + "dev:web": "pnpm codegen && rspeedy build --environment web && pnpm --filter sparkling-web-shell dev", + "pretest": "pnpm codegen", + "test": "vitest run --passWithNoTests" + }, + "dependencies": { + "@lynx-js/react": "^0.116.2", + "@tanstack/react-router": "1.170.17", + "sparkling-history": "workspace:*", + "sparkling-method": "workspace:*", + "sparkling-navigation": "workspace:*", + "sparkling-router": "workspace:*" + }, + "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", + "@tanstack/router-generator": "^1.167.18", + "@tanstack/router-plugin": "^1.168.19", + "typescript": "^5.8.3", + "vitest": "^3.2.4" + }, + "engines": { + "node": "^22 || ^24" + } +} diff --git a/packages/tanstack-router-demo/scripts/codegen.mjs b/packages/tanstack-router-demo/scripts/codegen.mjs new file mode 100644 index 00000000..68f78d0c --- /dev/null +++ b/packages/tanstack-router-demo/scripts/codegen.mjs @@ -0,0 +1,27 @@ +// 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. +// +// One-shot codegen for the file-based MPA demo: +// 1. routeTree.gen.ts — via the official @tanstack/router-generator +// 2. routes.manifest.ts + page entries — via our MPA extension (gen-mpa.mjs) +// +// The rspeedy build also regenerates (1) on the fly via the router-plugin, but +// (2) must exist before the build starts (lynx.config.ts imports the generated +// page-entries), and both must exist for `vitest` / `tsc`. Run this first. +import { Generator, getConfig } from '@tanstack/router-generator'; +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const root = process.cwd(); + +// 1. Official TanStack route-tree generator (standalone, no bundler). +const config = await getConfig({}, root); +await new Generator({ config, root }).run(); + +// 2. Our MPA manifest + per-page entries. +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'); diff --git a/packages/tanstack-router-demo/scripts/gen-mpa.mjs b/packages/tanstack-router-demo/scripts/gen-mpa.mjs new file mode 100644 index 00000000..cc09deae --- /dev/null +++ b/packages/tanstack-router-demo/scripts/gen-mpa.mjs @@ -0,0 +1,154 @@ +// 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. +// +// 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: +// +// 1. src/routes.manifest.ts — the route -> native-page (bundle) mapping +// 2. src/pages.gen//index.tsx — one bundle entry per native page +// +// 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'; +import { join, resolve } from 'node:path'; + +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); +} + +/** Extract and evaluate the `export const page = { ... }` object literal. */ +function extractPage(src) { + const idx = src.search(/export\s+const\s+page\s*=\s*\{/); + if (idx === -1) 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 main() { + const files = readdirSync(ROUTES_DIR).filter( + (f) => /\.(tsx?|jsx?)$/.test(f) && !f.startsWith('__'), + ); + + const routes = files.map((file) => { + const src = readFileSync(join(ROUTES_DIR, file), 'utf8'); + return { file, path: extractRoutePath(src, file), page: extractPage(src) }; + }); + + 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 }>} */ + 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; + ensure(pageId, containerParams).paths.add(r.path); + } + + const pages = [...byId.values()].map((p) => ({ + id: p.id, + paths: [...p.paths].sort(), + ...(p.containerParams ? { containerParams: p.containerParams } : {}), + })); + + // 1. Write the manifest module. + const containers = pages.map((page) => ({ + id: page.id, + bundle: `${page.id}.lynx.bundle`, + presentation: 'push', + routes: page.paths.map((path) => ({ path })), + ...(page.containerParams ? { containerOptions: page.containerParams } : {}), + })); + 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 { RouteManifest } from 'sparkling-router';\n\n` + + `export const manifest: RouteManifest = ${JSON.stringify( + { + version: 'prototype-1', + scheme: { base: 'hybrid://lynxview_page' }, + containers, + }, + null, + 2, + )};\n`; + writeFileSync(MANIFEST_OUT, manifestBody); + + // 2. Write one entry per page. + 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 = + `// 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.\n` + + `import { mount } from '../../mpa/mount.js';\n\n` + + `mount();\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. + const entriesBody = + `// AUTO-GENERATED by scripts/gen-mpa.mjs — do not edit.\n` + + `export const pageEntries = ${JSON.stringify( + Object.fromEntries(entryList.map((e) => [e.id, e.entry])), + null, + 2, + )};\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(' ')); +} + +main(); diff --git a/packages/tanstack-router-demo/src/mpa/create-router.tsx b/packages/tanstack-router-demo/src/mpa/create-router.tsx new file mode 100644 index 00000000..2fc64641 --- /dev/null +++ b/packages/tanstack-router-demo/src/mpa/create-router.tsx @@ -0,0 +1,75 @@ +// 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 { + createRouter, + createMpaHistory, + createManifestPageResolver, +} from 'sparkling-router'; +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'; + +/** + * Read this page's launch query params. On a real sparkling page these are + * injected by the native container (or, on the web harness, by the shell) as + * `lynx.__globalProps.queryItems`. + */ +function readQueryItems(): Record { + // `lynx` is a bare ambient global in the Lynx runtime (not necessarily on + // globalThis), so reference it directly with a guarded fallback. + const g = globalThis as { + lynx?: { __globalProps?: { queryItems?: Record } }; + }; + const lynxGlobal = + typeof lynx !== 'undefined' + ? (lynx as { __globalProps?: { queryItems?: Record } }) + : g.lynx; + return lynxGlobal?.__globalProps?.queryItems ?? {}; +} + +function LynxErrorComponent({ error }: { error: Error }) { + console.error('[mpa] route error:', error.message, error.stack); + return ( + + {`Error: ${error.message}`} + + ); +} + +/** + * Build a TanStack Router wired to sparkling-navigation through the history + * shim. Cross-page navigations become native page opens; in-page navigations + * stay within this bundle. + */ +export function createMpaRouter() { + const host = createSparklingHost({ + navigation: navigation as never, + getQueryItems: readQueryItems, + }); + + const history = createMpaHistory({ + host, + resolvePage: createManifestPageResolver(manifest), + onHostError: (e) => console.error('[mpa] host error:', e), + }); + + return createRouter({ + routeTree, + history: history as never, + isServer: false, + // router-core reads a bare `window.origin` when isServer is false; a + // native Lynx runtime has no `window`, so pin the origin explicitly. + origin: 'http://sparkling.local', + defaultErrorComponent: LynxErrorComponent as never, + defaultNotFoundComponent: (() => ( + + Not found + + )) as never, + }); +} diff --git a/packages/tanstack-router-demo/src/mpa/mount.tsx b/packages/tanstack-router-demo/src/mpa/mount.tsx new file mode 100644 index 00000000..637cbba8 --- /dev/null +++ b/packages/tanstack-router-demo/src/mpa/mount.tsx @@ -0,0 +1,17 @@ +// 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 '../shims/env.js'; +import { root } from '@lynx-js/react'; +import { RouterProvider } from '@tanstack/react-router'; +import { createMpaRouter } from './create-router.js'; + +/** + * Boot a page. Every page bundle calls this; the router derives its initial + * location from the launch queryItems (`__mpa_href`), so the same code renders + * Home in the home bundle and Detail in the detail bundle. + */ +export function mount() { + const router = createMpaRouter(); + root.render(); +} diff --git a/packages/tanstack-router-demo/src/page-entries.gen.ts b/packages/tanstack-router-demo/src/page-entries.gen.ts new file mode 100644 index 00000000..5b292a24 --- /dev/null +++ b/packages/tanstack-router-demo/src/page-entries.gen.ts @@ -0,0 +1,6 @@ +// AUTO-GENERATED by scripts/gen-mpa.mjs — do not edit. +export const pageEntries = { + "detail": "./src/pages.gen/detail/index.tsx", + "home": "./src/pages.gen/home/index.tsx", + "settings": "./src/pages.gen/settings/index.tsx" +}; diff --git a/packages/tanstack-router-demo/src/pages.gen/detail/index.tsx b/packages/tanstack-router-demo/src/pages.gen/detail/index.tsx new file mode 100644 index 00000000..efd08e0a --- /dev/null +++ b/packages/tanstack-router-demo/src/pages.gen/detail/index.tsx @@ -0,0 +1,7 @@ +// 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. +import { mount } from '../../mpa/mount.js'; + +mount(); diff --git a/packages/tanstack-router-demo/src/pages.gen/home/index.tsx b/packages/tanstack-router-demo/src/pages.gen/home/index.tsx new file mode 100644 index 00000000..7087903d --- /dev/null +++ b/packages/tanstack-router-demo/src/pages.gen/home/index.tsx @@ -0,0 +1,7 @@ +// 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. +import { mount } from '../../mpa/mount.js'; + +mount(); diff --git a/packages/tanstack-router-demo/src/pages.gen/settings/index.tsx b/packages/tanstack-router-demo/src/pages.gen/settings/index.tsx new file mode 100644 index 00000000..f4a2535c --- /dev/null +++ b/packages/tanstack-router-demo/src/pages.gen/settings/index.tsx @@ -0,0 +1,7 @@ +// 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. +import { mount } from '../../mpa/mount.js'; + +mount(); diff --git a/packages/tanstack-router-demo/src/routeTree.gen.ts b/packages/tanstack-router-demo/src/routeTree.gen.ts new file mode 100644 index 00000000..d4130fd6 --- /dev/null +++ b/packages/tanstack-router-demo/src/routeTree.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/__root'; +import { Route as IndexRouteImport } from './routes/index'; +import { Route as ProfileRouteImport } from './routes/profile'; +import { Route as SettingsRouteImport } from './routes/settings'; +import { Route as DetailIdRouteImport } from './routes/detail.$id'; + +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any); +const ProfileRoute = ProfileRouteImport.update({ + id: '/profile', + path: '/profile', + getParentRoute: () => rootRouteImport, +} as any); +const SettingsRoute = SettingsRouteImport.update({ + id: '/settings', + path: '/settings', + 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 { + '/': { + id: '/'; + path: '/'; + fullPath: '/'; + preLoaderRoute: typeof IndexRouteImport; + parentRoute: typeof rootRouteImport; + }; + '/profile': { + id: '/profile'; + path: '/profile'; + fullPath: '/profile'; + preLoaderRoute: typeof ProfileRouteImport; + parentRoute: typeof rootRouteImport; + }; + '/settings': { + id: '/settings'; + path: '/settings'; + fullPath: '/settings'; + preLoaderRoute: typeof SettingsRouteImport; + 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.manifest.ts b/packages/tanstack-router-demo/src/routes.manifest.ts new file mode 100644 index 00000000..71c4d940 --- /dev/null +++ b/packages/tanstack-router-demo/src/routes.manifest.ts @@ -0,0 +1,51 @@ +// AUTO-GENERATED by scripts/gen-mpa.mjs — do not edit. +// Route -> native-page (bundle) mapping, derived from src/routes/*. +import type { RouteManifest } from 'sparkling-router'; + +export const manifest: RouteManifest = { + "version": "prototype-1", + "scheme": { + "base": "hybrid://lynxview_page" + }, + "containers": [ + { + "id": "detail", + "bundle": "detail.lynx.bundle", + "presentation": "push", + "routes": [ + { + "path": "/detail/$id" + } + ], + "containerOptions": { + "title": "Detail" + } + }, + { + "id": "home", + "bundle": "home.lynx.bundle", + "presentation": "push", + "routes": [ + { + "path": "/" + }, + { + "path": "/profile" + } + ] + }, + { + "id": "settings", + "bundle": "settings.lynx.bundle", + "presentation": "push", + "routes": [ + { + "path": "/settings" + } + ], + "containerOptions": { + "title": "Settings" + } + } + ] +}; diff --git a/packages/tanstack-router-demo/src/routes/__root.tsx b/packages/tanstack-router-demo/src/routes/__root.tsx new file mode 100644 index 00000000..27276ccd --- /dev/null +++ b/packages/tanstack-router-demo/src/routes/__root.tsx @@ -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. +import { createRootRoute, Outlet } from '@tanstack/react-router'; + +export const Route = createRootRoute({ + component: () => , +}); diff --git a/packages/tanstack-router-demo/src/routes/detail.$id.tsx b/packages/tanstack-router-demo/src/routes/detail.$id.tsx new file mode 100644 index 00000000..6264ce6c --- /dev/null +++ b/packages/tanstack-router-demo/src/routes/detail.$id.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 { createFileRoute, useNavigate, useRouter } from '@tanstack/react-router'; +import { Screen, NavButton } from '../ui.js'; + +// MPA extension: this route starts its own native page (bundle / JS context). +// The manifest codegen reads this to build the route->page mapping that +// TanStack's own generator does not produce. +export const page = { id: 'detail', containerParams: { title: 'Detail' } }; + +export const Route = createFileRoute('/detail/$id')({ + validateSearch: (search: Record) => ({ + ref: typeof search.ref === 'string' ? search.ref : undefined, + }), + component: DetailPage, +}); + +function DetailPage() { + const navigate = useNavigate(); + const router = useRouter(); + const { id } = Route.useParams(); + const search = Route.useSearch(); + return ( + + + {`Path param id=${id}, search ref=${(search as { ref?: string }).ref ?? '?'}.`} + + + This page booted in its own JS context; params arrived via the scheme. + + navigate({ to: '/detail/$id', params: { id: '43' }, search: { ref: 'detail' } })} + /> + router.history.back()} /> + + ); +} diff --git a/packages/tanstack-router-demo/src/routes/index.tsx b/packages/tanstack-router-demo/src/routes/index.tsx new file mode 100644 index 00000000..e89846b2 --- /dev/null +++ b/packages/tanstack-router-demo/src/routes/index.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 { createFileRoute, useNavigate } from '@tanstack/react-router'; +import { Screen, NavButton } from '../ui.js'; + +// MPA extension: the root page. Any route without its own `page` export +// (e.g. /profile) belongs to this bundle. +export const page = { id: 'home', root: true }; + +export const Route = createFileRoute('/')({ + component: HomePage, +}); + +function HomePage() { + const navigate = useNavigate(); + return ( + + + Home and Profile share one bundle (in-page nav). Detail and Settings are + separate native pages. + + navigate({ to: '/profile' })} + /> + navigate({ to: '/detail/$id', params: { id: '42' }, search: { ref: 'home' } })} + /> + navigate({ to: '/settings' })} + /> + + ); +} diff --git a/packages/tanstack-router-demo/src/routes/profile.tsx b/packages/tanstack-router-demo/src/routes/profile.tsx new file mode 100644 index 00000000..29f84f17 --- /dev/null +++ b/packages/tanstack-router-demo/src/routes/profile.tsx @@ -0,0 +1,22 @@ +// 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 { createFileRoute, useNavigate } from '@tanstack/react-router'; +import { Screen, NavButton } from '../ui.js'; + +export const Route = createFileRoute('/profile')({ + component: ProfilePage, +}); + +function ProfilePage() { + const navigate = useNavigate(); + return ( + + + This is an in-page route inside the Home bundle — no native page was + opened to get here. + + navigate({ to: '/' })} /> + + ); +} diff --git a/packages/tanstack-router-demo/src/routes/settings.tsx b/packages/tanstack-router-demo/src/routes/settings.tsx new file mode 100644 index 00000000..e3dc7c9f --- /dev/null +++ b/packages/tanstack-router-demo/src/routes/settings.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 { 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' } }; + +export const Route = createFileRoute('/settings')({ + component: SettingsPage, +}); + +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/shims/env.ts b/packages/tanstack-router-demo/src/shims/env.ts new file mode 100644 index 00000000..97fde8ad --- /dev/null +++ b/packages/tanstack-router-demo/src/shims/env.ts @@ -0,0 +1,54 @@ +// Minimal global-environment polyfills for running TanStack Router in a +// Lynx JS context (no window/document). +// +// - scrollTo: router-core's reset-scroll-on-navigation subscription calls the +// bare `scrollTo(...)` global even when scrollRestoration is disabled. +// Scrolling is a per-element concern on Lynx, so a no-op is correct. +// - queueMicrotask / AbortController: guaranteed in web workers (the web +// harness) but not in every native Lynx JS runtime; provide fallbacks. +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); + }; +} + +if (typeof g.AbortController !== 'function') { + class AbortSignalShim { + aborted = false; + reason: unknown = undefined; + private listeners = new Set<() => void>(); + addEventListener(type: string, cb: () => void) { + if (type === 'abort') this.listeners.add(cb); + } + removeEventListener(type: string, cb: () => void) { + if (type === 'abort') this.listeners.delete(cb); + } + throwIfAborted() { + if (this.aborted) throw this.reason; + } + _abort(reason: unknown) { + if (this.aborted) return; + this.aborted = true; + this.reason = reason; + this.listeners.forEach((cb) => cb()); + } + onabort: (() => void) | null = null; + } + class AbortControllerShim { + signal = new AbortSignalShim(); + abort(reason?: unknown) { + this.signal._abort(reason ?? new Error('Aborted')); + this.signal.onabort?.(); + } + } + g.AbortController = AbortControllerShim; + g.AbortSignal = AbortSignalShim; +} + +export {}; diff --git a/packages/tanstack-router-demo/src/shims/react-dom.ts b/packages/tanstack-router-demo/src/shims/react-dom.ts new file mode 100644 index 00000000..761b322d --- /dev/null +++ b/packages/tanstack-router-demo/src/shims/react-dom.ts @@ -0,0 +1,14 @@ +// Minimal `react-dom` surface for ReactLynx. +// +// @tanstack/react-router's main entry imports exactly one symbol from +// react-dom at module scope: `flushSync` (used by to synchronously +// flip its `isTransitioning` state before navigating). ReactLynx is +// Preact-based and renders synchronously outside of batched contexts, so +// executing the callback directly preserves the intended semantics. +// +// The bundler aliases `react-dom` to this file (see lynx.config.ts). +export function flushSync(fn: () => R): R { + return fn(); +} + +export default { flushSync }; diff --git a/packages/tanstack-router-demo/src/shims/react.ts b/packages/tanstack-router-demo/src/shims/react.ts new file mode 100644 index 00000000..8b0451f9 --- /dev/null +++ b/packages/tanstack-router-demo/src/shims/react.ts @@ -0,0 +1,21 @@ +// `react` shim for running TanStack Router on ReactLynx. +// +// ReactLynx's `react` alias (@lynx-js/react) lacks a few React 18/19 APIs +// that @tanstack/react-router's dist accesses as named ESM bindings: +// - startTransition / useTransition — provided by @lynx-js/react/compat +// - use — React 19 only; TanStack falls back to a Suspense-throwing shim +// when it is undefined, so exporting undefined is sufficient (it only +// needs the binding to exist for strict ESM linking). +// +// The bundler aliases `react$` to this file; @lynx-js/react imports below +// still resolve through ReactLynx's per-thread layer aliases. +import * as LynxReact from '@lynx-js/react'; + +export * from '@lynx-js/react'; +export { startTransition, useTransition } from '@lynx-js/react/compat'; + +export const use = undefined; + +// @lynx-js/react provides a runtime default export (the lepus namespace) even +// though its published types do not declare one; fall back to the namespace. +export default (LynxReact as { default?: unknown }).default ?? LynxReact; diff --git a/packages/tanstack-router-demo/src/spike/index.tsx b/packages/tanstack-router-demo/src/spike/index.tsx new file mode 100644 index 00000000..6011a36b --- /dev/null +++ b/packages/tanstack-router-demo/src/spike/index.tsx @@ -0,0 +1,111 @@ +// Feasibility spike: run TanStack Router on ReactLynx with a memory history. +// This intentionally avoids (which renders an tag) and drives +// navigation through useNavigate + bindtap instead. +import '../shims/env.js'; +import { root } from '@lynx-js/react'; +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + Outlet, + RouterProvider, + useNavigate, + useRouterState, +} from '@tanstack/react-router'; + +function RootLayout() { + const state = useRouterState(); + return ( + + + {`TSR spike — location: ${state.location.pathname}`} + + + + ); +} + +const rootRoute = createRootRoute({ component: RootLayout }); + +function IndexPage() { + const navigate = useNavigate(); + return ( + + Index route + { + navigate({ to: '/about', search: { from: 'index' } }); + }} + > + → go to /about + + + ); +} + +const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexPage, +}); + +function AboutPage() { + const navigate = useNavigate(); + const search = aboutRoute.useSearch(); + return ( + + + {`About route (from=${(search as { from?: string }).from ?? '?'})`} + + { + navigate({ to: '/' }); + }} + > + ← back to / + + + ); +} + +const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + validateSearch: (search: Record) => ({ + from: typeof search.from === 'string' ? search.from : undefined, + }), + component: AboutPage, +}); + +const routeTree = rootRoute.addChildren([indexRoute, aboutRoute]); + +function LynxErrorComponent({ error }: { error: Error }) { + console.error('[spike] route error:', error.message, error.stack); + return ( + + {`Error: ${error.message}`} + {String(error.stack ?? '')} + + ); +} + +const router = createRouter({ + routeTree, + history: createMemoryHistory({ initialEntries: ['/'] }), + isServer: false, + defaultErrorComponent: LynxErrorComponent as never, + defaultNotFoundComponent: (() => ( + + Not found + + )) as never, +}); + +function App() { + return ; +} + +root.render(); diff --git a/packages/tanstack-router-demo/src/ui.tsx b/packages/tanstack-router-demo/src/ui.tsx new file mode 100644 index 00000000..952cd397 --- /dev/null +++ b/packages/tanstack-router-demo/src/ui.tsx @@ -0,0 +1,37 @@ +// 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 screen UI, used by the file-based routes (src/routes/*). +import { useRouterState } from '@tanstack/react-router'; + +export function Screen(props: { title: string; accent: string; children?: unknown }) { + const state = useRouterState(); + return ( + + + {`location: ${state.location.pathname}${state.location.searchStr || ''}`} + + + {props.title} + + {props.children as never} + + ); +} + +export function NavButton(props: { label: string; color: string; onTap: () => void }) { + return ( + + {props.label} + + ); +} diff --git a/packages/tanstack-router-demo/tests/generated-tree.test.ts b/packages/tanstack-router-demo/tests/generated-tree.test.ts new file mode 100644 index 00000000..211c4b9e --- /dev/null +++ b/packages/tanstack-router-demo/tests/generated-tree.test.ts @@ -0,0 +1,76 @@ +// 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. +// +// Proves the *generated* routeTree.gen.ts (produced by @tanstack/router-generator +// with no bundler) drives navigation over createMpaHistory — i.e. the official +// file-based generator is directly reusable in a Rspeedy/Lynx project. +import { describe, expect, test } from 'vitest'; +import { + createManifestPageResolver, + createMpaHistory, + createRouter, +} from 'sparkling-router'; +import { createMemoryHost } from 'sparkling-history'; +import { routeTree } from '../src/routeTree.gen.js'; +import { manifest } from '../src/routes.manifest.js'; + +const ORIGIN = 'http://sparkling.local'; + +describe('generated routeTree.gen.ts + generated manifest', () => { + test('boots at the generated index route', async () => { + const router = createRouter({ + routeTree, + history: createMpaHistory({ host: createMemoryHost({ initialHref: '/' }) }) as never, + isServer: false, + origin: ORIGIN, + }); + await router.load(); + expect(router.state.location.pathname).toBe('/'); + }); + + test('generated path param route resolves', async () => { + const router = createRouter({ + routeTree, + history: createMpaHistory({ host: createMemoryHost({ initialHref: '/detail/7?ref=x' }) }) as never, + isServer: false, + origin: ORIGIN, + }); + 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'); + }); + + test('generated manifest routes cross-page navigation to the right bundle', async () => { + const host = createMemoryHost({ initialHref: '/' }); + const history = createMpaHistory({ host, resolvePage: createManifestPageResolver(manifest) }); + const router = createRouter({ + routeTree, + history: history as never, + isServer: false, + origin: ORIGIN, + }); + 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('manifest keeps /profile in the home bundle (in-page)', async () => { + const host = createMemoryHost({ initialHref: '/' }); + const history = createMpaHistory({ host, resolvePage: createManifestPageResolver(manifest) }); + const router = createRouter({ + routeTree, + history: history as never, + isServer: false, + origin: ORIGIN, + }); + await router.load(); + await router.navigate({ to: '/profile' }); + await router.invalidate(); + // In-page: no native open, location moved within the bundle. + expect(host.opens).toHaveLength(0); + expect(router.state.location.pathname).toBe('/profile'); + }); +}); diff --git a/packages/tanstack-router-demo/tests/router-features.test.ts b/packages/tanstack-router-demo/tests/router-features.test.ts new file mode 100644 index 00000000..82c88df8 --- /dev/null +++ b/packages/tanstack-router-demo/tests/router-features.test.ts @@ -0,0 +1,214 @@ +// 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. +// +// Empirical feature-support matrix. These drive a REAL @tanstack/react-router +// instance headlessly over `createMpaHistory`, exercising each router feature +// and asserting the outcome. This is the evidence behind the support matrix in +// docs/en/guide/tanstack-router.md: a passing test here means "supported in +// the in-page subset"; the mpa.test.ts / sparkling-host.test.ts suites cover +// the cross-page (native) behaviors. +// +// `origin` is passed explicitly to every router: router-core reads a bare +// `window` global otherwise, which is undeclared in a native Lynx runtime. +import { describe, expect, test, vi } from 'vitest'; +import { + createRootRoute, + createRoute, + createRouter, + redirect, + notFound, +} from '@tanstack/react-router'; +import { createMpaHistory, createMemoryHost, createManifestPageResolver } from 'sparkling-history'; +import type { PageManifest } from 'sparkling-history'; + +const ORIGIN = 'http://sparkling.local'; + +function mkRouter(routeTree: unknown, initialHref = '/') { + const history = createMpaHistory({ host: createMemoryHost({ initialHref }) }); + return createRouter({ + routeTree: routeTree as never, + history: history as never, + isServer: false, + origin: ORIGIN, + }); +} + +describe('TanStack Router feature support over createMpaHistory (in-page subset)', () => { + test('nested routes + outlet: parent and child both match', async () => { + const root = createRootRoute(); + const layout = createRoute({ getParentRoute: () => root, id: 'layout' }); + const child = createRoute({ getParentRoute: () => layout, path: '/child' }); + const tree = root.addChildren([layout.addChildren([child])]); + const router = mkRouter(tree, '/child'); + await router.load(); + const ids = router.state.matches.map((m) => m.routeId); + // root + pathless layout + child all participate in the match chain. + expect(ids).toContain('/layout'); + expect(ids).toContain(child.id); + expect(ids.length).toBe(3); + }); + + test('path params are parsed', async () => { + const root = createRootRoute(); + const detail = createRoute({ getParentRoute: () => root, path: '/detail/$id' }); + const router = mkRouter(root.addChildren([detail]), '/detail/99'); + await router.load(); + const match = router.state.matches.find((m) => m.routeId === detail.id); + expect((match?.params as { id?: string }).id).toBe('99'); + }); + + test('validated search params', async () => { + const root = createRootRoute(); + const search = createRoute({ + getParentRoute: () => root, + path: '/s', + // The default search parser coerces `n=7` to the number 7, so validate + // by coercing whatever type arrives. + validateSearch: (s: Record) => ({ n: Number(s.n ?? 0) }), + }); + const router = mkRouter(root.addChildren([search]), '/s?n=7'); + await router.load(); + const match = router.state.matches.find((m) => m.routeId === search.id); + expect((match?.search as { n?: number }).n).toBe(7); + }); + + test('loaders run and expose loaderData', async () => { + const root = createRootRoute(); + const idx = createRoute({ + getParentRoute: () => root, + path: '/', + loader: async () => ({ value: 42 }), + }); + const router = mkRouter(root.addChildren([idx])); + await router.load(); + const match = router.state.matches.find((m) => m.routeId === idx.id); + expect((match?.loaderData as { value?: number }).value).toBe(42); + }); + + test('beforeLoad + route context', async () => { + const root = createRootRoute(); + const idx = createRoute({ + getParentRoute: () => root, + path: '/', + beforeLoad: () => ({ user: 'alice' }), + loader: ({ context }: { context: { user: string } }) => ({ who: context.user }), + }); + const router = mkRouter(root.addChildren([idx])); + await router.load(); + const match = router.state.matches.find((m) => m.routeId === idx.id); + expect((match?.loaderData as { who?: string }).who).toBe('alice'); + }); + + test('redirect() in beforeLoad changes the resolved location', async () => { + const root = createRootRoute(); + const guarded = createRoute({ + getParentRoute: () => root, + path: '/guarded', + beforeLoad: () => { + throw redirect({ to: '/login' }); + }, + }); + const login = createRoute({ getParentRoute: () => root, path: '/login' }); + const router = mkRouter(root.addChildren([guarded, login]), '/guarded'); + await router.load(); + expect(router.state.location.pathname).toBe('/login'); + }); + + test('notFound() surfaces a not-found match', async () => { + const root = createRootRoute(); + const idx = createRoute({ + getParentRoute: () => root, + path: '/', + loader: () => { + throw notFound(); + }, + }); + const router = mkRouter(root.addChildren([idx])); + await router.load(); + const match = router.state.matches.find((m) => m.routeId === idx.id); + expect(match?.status).toBe('notFound'); + }); + + test('loader errors surface as an error match', async () => { + const root = createRootRoute(); + const idx = createRoute({ + getParentRoute: () => root, + path: '/', + loader: () => { + throw new Error('boom'); + }, + }); + const router = mkRouter(root.addChildren([idx])); + await router.load(); + const match = router.state.matches.find((m) => m.routeId === idx.id); + expect(match?.status).toBe('error'); + }); + + test('imperative navigate() updates location and matches', async () => { + const root = createRootRoute(); + const idx = createRoute({ getParentRoute: () => root, path: '/' }); + const about = createRoute({ getParentRoute: () => root, path: '/about' }); + const router = mkRouter(root.addChildren([idx, about])); + await router.load(); + await router.navigate({ to: '/about' }); + await router.invalidate(); + expect(router.state.location.pathname).toBe('/about'); + }); + + test('route masking: masked location differs from real location', async () => { + const root = createRootRoute(); + const idx = createRoute({ getParentRoute: () => root, path: '/' }); + const photo = createRoute({ getParentRoute: () => root, path: '/photo/$id' }); + const router = mkRouter(root.addChildren([idx, photo])); + await router.load(); + await router.navigate({ to: '/photo/$id', params: { id: '1' }, mask: { to: '/' } as never }); + await router.invalidate(); + // The real location is the photo; the mask records '/' in history state. + expect(router.state.location.pathname).toBe('/photo/1'); + expect(router.state.location.maskedLocation?.pathname).toBe('/'); + }); +}); + +describe('navigation blocking works with no DOM (createMpaHistory guarantee)', () => { + test('a blocker prevents an in-page push even without a global document', async () => { + expect(typeof (globalThis as { document?: unknown }).document).toBe('undefined'); + const host = createMemoryHost({ initialHref: '/' }); + const history = createMpaHistory({ host }); + const blockerFn = vi.fn(() => true); + history.block({ blockerFn }); + await history.push('/blocked'); + expect(history.location.pathname).toBe('/'); + expect(blockerFn).toHaveBeenCalledTimes(1); + }); +}); + +describe('cross-page navigation is forwarded to the host (MPA behavior)', () => { + const manifest: PageManifest = { + pages: [ + { id: 'home', paths: ['/'] }, + { id: 'detail', paths: ['/detail'] }, + ], + }; + + test('router.navigate to another page calls host.open, not an in-page transition', async () => { + const root = createRootRoute(); + const idx = createRoute({ getParentRoute: () => root, path: '/' }); + const detail = createRoute({ getParentRoute: () => root, path: '/detail/$id' }); + const host = createMemoryHost({ initialHref: '/' }); + const history = createMpaHistory({ host, resolvePage: createManifestPageResolver(manifest) }); + const router = createRouter({ + routeTree: root.addChildren([idx, detail]) as never, + history: history as never, + isServer: false, + origin: ORIGIN, + }); + await router.load(); + await router.navigate({ to: '/detail/$id', params: { id: '7' } }); + // The current page stays put; the host was asked to open the detail page. + expect(router.state.location.pathname).toBe('/'); + expect(host.opens).toHaveLength(1); + expect(host.opens[0]!.page.id).toBe('detail'); + expect(host.opens[0]!.href).toContain('/detail/7'); + }); +}); diff --git a/packages/tanstack-router-demo/tsconfig.json b/packages/tanstack-router-demo/tsconfig.json new file mode 100644 index 00000000..132d3e11 --- /dev/null +++ b/packages/tanstack-router-demo/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "jsxImportSource": "@lynx-js/react", + "strict": true, + "skipLibCheck": true, + "isolatedModules": true, + "resolveJsonModule": true, + "noEmit": true, + "types": ["@lynx-js/types"] + }, + "include": ["src"] +} diff --git a/packages/tanstack-router-demo/tsr.config.json b/packages/tanstack-router-demo/tsr.config.json new file mode 100644 index 00000000..07bbf90f --- /dev/null +++ b/packages/tanstack-router-demo/tsr.config.json @@ -0,0 +1,8 @@ +{ + "routesDirectory": "./src/routes", + "generatedRouteTree": "./src/routeTree.gen.ts", + "target": "react", + "autoCodeSplitting": false, + "disableTypes": false, + "semicolons": true +} diff --git a/packages/tanstack-router-demo/vitest.config.ts b/packages/tanstack-router-demo/vitest.config.ts new file mode 100644 index 00000000..d154d2f0 --- /dev/null +++ b/packages/tanstack-router-demo/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + // Node, not jsdom: these tests double as proof that the router core + + // history shim drive navigation with no DOM globals present. + environment: 'node', + include: ['tests/**/*.test.ts'], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a974c7f2..ec3becdd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -172,7 +172,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.6.1)(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)(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 @@ -190,7 +190,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.6.1)(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)(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: @@ -240,6 +240,18 @@ importers: packages/sparkling-debug-tool: {} + packages/sparkling-history: + devDependencies: + sparkling-navigation: + specifier: workspace:* + version: link:../methods/sparkling-navigation + typescript: + specifier: ^5.8.3 + version: 5.9.3 + vitest: + specifier: ^3.2.4 + version: 3.2.4(@types/debug@4.1.13)(@types/node@25.6.0)(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/sparkling-method: devDependencies: '@lynx-js/types': @@ -295,6 +307,44 @@ importers: specifier: ^10.9.2 version: 10.9.2(@types/node@22.19.17)(typescript@5.9.3) + packages/sparkling-router: + dependencies: + '@tanstack/history': + specifier: 1.162.0 + version: 1.162.0 + '@tanstack/react-router': + specifier: 1.170.17 + version: 1.170.17(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@tanstack/router-core': + specifier: 1.171.14 + version: 1.171.14 + sparkling-history: + specifier: workspace:* + version: link:../sparkling-history + devDependencies: + typescript: + specifier: ^5.8.3 + version: 5.9.3 + vitest: + specifier: ^3.2.4 + version: 3.2.4(@types/debug@4.1.13)(@types/node@25.6.0)(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/sparkling-router-plugin: + dependencies: + sparkling-router: + specifier: workspace:* + version: link:../sparkling-router + typescript: + specifier: ^5.8.3 + version: 5.9.3 + devDependencies: + '@types/node': + specifier: ^22.15.17 + version: 22.19.17 + vitest: + specifier: ^3.2.4 + version: 3.2.4(@types/debug@4.1.13)(@types/node@22.19.17)(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/sparkling-sdk: {} packages/sparkling-types: @@ -306,6 +356,52 @@ importers: specifier: ^5.8.3 version: 5.9.3 + packages/tanstack-router-demo: + dependencies: + '@lynx-js/react': + specifier: ^0.116.2 + version: 0.116.5(@lynx-js/types@3.7.0)(@types/react@19.2.14) + '@tanstack/react-router': + specifier: 1.170.17 + version: 1.170.17(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + sparkling-history: + specifier: workspace:* + version: link:../sparkling-history + sparkling-method: + specifier: workspace:* + version: link:../sparkling-method + sparkling-navigation: + specifier: workspace:* + version: link:../methods/sparkling-navigation + sparkling-router: + specifier: workspace:* + version: link:../sparkling-router + 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@19.2.14))(tslib@2.8.1)(webpack@5.105.0(esbuild@0.27.7)) + '@lynx-js/rspeedy': + specifier: ^0.13.3 + version: 0.13.6(@rspack/core@1.7.11(@swc/helpers@0.5.21))(esbuild@0.27.7)(typescript@5.9.3)(webpack@5.105.0(esbuild@0.27.7)) + '@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@19.2.14)) + '@tanstack/router-generator': + specifier: ^1.167.18 + version: 1.167.21 + '@tanstack/router-plugin': + specifier: ^1.168.19 + version: 1.168.23(@rsbuild/core@1.7.3)(@rspack/core@1.7.11(@swc/helpers@0.5.21))(@tanstack/react-router@1.170.17(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(esbuild@0.27.7)(rollup@4.60.1)(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))(webpack@5.105.0(esbuild@0.27.7)) + typescript: + specifier: ^5.8.3 + version: 5.9.3 + vitest: + specifier: ^3.2.4 + version: 3.2.4(@types/debug@4.1.13)(@types/node@25.6.0)(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/website: dependencies: '@douyinfe/semi-icons': @@ -420,7 +516,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.6.1)(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)(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: @@ -1866,6 +1962,67 @@ packages: '@swc/helpers@0.5.21': resolution: {integrity: sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==} + '@tanstack/history@1.162.0': + resolution: {integrity: sha512-79pf/RkhteYZTRgcR4F9kbk84P2N8rugQJswxfIqovlbRiT3yI7eBE+5QorIrZaOKktsgzRlXh1l/du/xpl4iA==} + engines: {node: '>=20.19'} + + '@tanstack/react-router@1.170.17': + resolution: {integrity: sha512-ppLkjCfSMaeug9rmFRYzOd4TIqWV+yTE7tzIny7alJsSnM7w4lzEZm6eqCehG0SPetpZ0R3K+UnanSmBgOAVcQ==} + engines: {node: '>=20.19'} + peerDependencies: + react: '>=18.0.0 || >=19.0.0' + react-dom: '>=18.0.0 || >=19.0.0' + + '@tanstack/react-store@0.9.3': + resolution: {integrity: sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/router-core@1.171.14': + resolution: {integrity: sha512-Mo3hwx0qB0cJsVYGDjG0+Ouf7VV74h/vsoDMGztdlyzDanp4gBA2s7IVvm6hFrmQM6GpD9F0Z7SqD7OldfLE7g==} + engines: {node: '>=20.19'} + + '@tanstack/router-core@1.171.15': + resolution: {integrity: sha512-IILCDcLaItMZQ2jEmCABHY1Nhjjn5XUvwpQp3e4Nmu+vfg0BgYFuu/QASz2SwE2ZNbVMrvt8X/wxa+Gg5aErxA==} + engines: {node: '>=20.19'} + + '@tanstack/router-generator@1.167.21': + resolution: {integrity: sha512-m3oXZyienj8owialdyoZ0txHQrnEx/Ra+D9kWtar5fC2cWZr5Pvxl86VY2mX5RRLC5QLKLeRGT1x4HV95wHVDQ==} + engines: {node: '>=20.19'} + + '@tanstack/router-plugin@1.168.23': + resolution: {integrity: sha512-0+PIcvnaAimFwjoEIeV3h7LKjzC8zNnp7pH2UamdKwQ9QlY99WU9V0Xl0zbM0i9hrUa/mKgWPDAzELmPUu5fMA==} + engines: {node: '>=20.19'} + peerDependencies: + '@rsbuild/core': '>=1.0.2 || ^2.0.0' + '@tanstack/react-router': ^1.170.18 + vite: '>=5.0.0 || >=6.0.0 || >=7.0.0 || >=8.0.0' + vite-plugin-solid: ^2.11.10 || ^3.0.0-0 + webpack: '>=5.92.0' + peerDependenciesMeta: + '@rsbuild/core': + optional: true + '@tanstack/react-router': + optional: true + vite: + optional: true + vite-plugin-solid: + optional: true + webpack: + optional: true + + '@tanstack/router-utils@1.162.2': + resolution: {integrity: sha512-hTWqJtqIFFdvuCl8WXNyrodp2L9zo2G37xKRrcVmVRWpAB2h+U1LuRAfS4tsFTiWOIoE/B+WDVFB8JpoEdw6jQ==} + engines: {node: '>=20.19'} + + '@tanstack/store@0.9.3': + resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==} + + '@tanstack/virtual-file-routes@1.162.0': + resolution: {integrity: sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA==} + engines: {node: '>=20.19'} + '@testing-library/jest-dom@6.9.1': resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} engines: {node: '>=14', npm: '>=6', yarn: '>=1'} @@ -2413,6 +2570,10 @@ packages: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} + ansis@4.3.1: + resolution: {integrity: sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==} + engines: {node: '>=14'} + anymatch@3.1.3: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} @@ -2469,6 +2630,9 @@ packages: axios@1.15.0: resolution: {integrity: sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==} + babel-dead-code-elimination@1.0.12: + resolution: {integrity: sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==} + babel-jest@29.7.0: resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -2728,6 +2892,9 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-es@3.1.1: + resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==} + cookie@0.7.2: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} @@ -2983,6 +3150,10 @@ packages: resolution: {integrity: sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==} engines: {node: '>=0.3.1'} + diff@8.0.4: + resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} + engines: {node: '>=0.3.1'} + dom-accessibility-api@0.6.3: resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} @@ -3656,6 +3827,10 @@ packages: isarray@2.0.5: resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + isbot@5.2.1: + resolution: {integrity: sha512-dJ+LpKyClQZ7NG+j3OensC/mAZkGpukE9YUrgPYvAZj2doVL0edfDgywTUh5CXa0o+nW9a1V9e5+CJTX8+SxRw==} + engines: {node: '>=18'} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -3827,6 +4002,10 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} @@ -5117,6 +5296,16 @@ packages: serialize-javascript@6.0.2: resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} + seroval-plugins@1.5.6: + resolution: {integrity: sha512-HXuLAX2pu/UByPpaeo/TaMfvMIi+1QqIoPJYCcAtU8QkVNwgR6MPlGuCQTErV1JwraaMbYaWVIBX7mppzGLATQ==} + engines: {node: '>=10'} + peerDependencies: + seroval: ^1.0 + + seroval@1.5.6: + resolution: {integrity: sha512-rVQVWjjSvlINzaQPZH5JFqsqEsIWdTxY3iJZCnTL/5gQbXIRooVZKI60tVCkOVfzcRPejboxO2t0P89dg5mQaA==} + engines: {node: '>=10'} + set-cookie-parser@2.7.2: resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} @@ -5647,6 +5836,39 @@ packages: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} + unplugin@3.3.0: + resolution: {integrity: sha512-qa66K+crbfyE6JK10GjvbJeRrOsuC/JpbnHctfyp/i4oBTxWOzJfRZyDiOk1PtErMFRu8JhsU/wPvOdBNWe5Rg==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@farmfe/core': '*' + '@rspack/core': '*' + bun-types-no-globals: '*' + esbuild: '*' + rolldown: '*' + rollup: '*' + unloader: '*' + vite: '*' + webpack: '*' + peerDependenciesMeta: + '@farmfe/core': + optional: true + '@rspack/core': + optional: true + bun-types-no-globals: + optional: true + esbuild: + optional: true + rolldown: + optional: true + rollup: + optional: true + unloader: + optional: true + vite: + optional: true + webpack: + optional: true + update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true @@ -5841,6 +6063,9 @@ packages: resolution: {integrity: sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q==} engines: {node: '>=10.13.0'} + webpack-virtual-modules@0.6.2: + resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + webpack@5.105.0: resolution: {integrity: sha512-gX/dMkRQc7QOMzgTe6KsYFM7DxeIONQSui1s0n/0xht36HvrgbxtM1xBlgx596NbpHuQU8P7QpKwrZYwUX48nw==} engines: {node: '>=10.13.0'} @@ -5975,6 +6200,9 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -6763,6 +6991,13 @@ snapshots: dependencies: '@lynx-js/webpack-runtime-globals': 0.0.6 + '@lynx-js/css-extract-webpack-plugin@0.7.0(@lynx-js/template-webpack-plugin@0.10.5(@lynx-js/lynx-core@0.1.3)(tslib@2.8.1))(webpack@5.105.0(esbuild@0.27.7))': + dependencies: + '@lynx-js/template-webpack-plugin': 0.10.5(@lynx-js/lynx-core@0.1.3)(tslib@2.8.1) + mini-css-extract-plugin: 2.10.2(webpack@5.105.0(esbuild@0.27.7)) + transitivePeerDependencies: + - webpack + '@lynx-js/css-extract-webpack-plugin@0.7.0(@lynx-js/template-webpack-plugin@0.10.5(@lynx-js/lynx-core@0.1.3)(tslib@2.8.1))(webpack@5.105.0)': dependencies: '@lynx-js/template-webpack-plugin': 0.10.5(@lynx-js/lynx-core@0.1.3)(tslib@2.8.1) @@ -6799,6 +7034,10 @@ snapshots: dependencies: '@lynx-js/react-webpack-plugin': 0.7.4(@lynx-js/react@0.116.5(@lynx-js/types@3.7.0)(@types/react@18.3.28))(@lynx-js/template-webpack-plugin@0.10.5(@lynx-js/lynx-core@0.1.3)(tslib@2.8.1)) + '@lynx-js/react-refresh-webpack-plugin@0.3.4(@lynx-js/react-webpack-plugin@0.7.4(@lynx-js/react@0.116.5(@lynx-js/types@3.7.0)(@types/react@19.2.14))(@lynx-js/template-webpack-plugin@0.10.5(@lynx-js/lynx-core@0.1.3)(tslib@2.8.1)))': + dependencies: + '@lynx-js/react-webpack-plugin': 0.7.4(@lynx-js/react@0.116.5(@lynx-js/types@3.7.0)(@types/react@19.2.14))(@lynx-js/template-webpack-plugin@0.10.5(@lynx-js/lynx-core@0.1.3)(tslib@2.8.1)) + '@lynx-js/react-rsbuild-plugin@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)': dependencies: '@lynx-js/css-extract-webpack-plugin': 0.7.0(@lynx-js/template-webpack-plugin@0.10.5(@lynx-js/lynx-core@0.1.3)(tslib@2.8.1))(webpack@5.105.0) @@ -6816,6 +7055,23 @@ snapshots: - tslib - webpack + '@lynx-js/react-rsbuild-plugin@0.12.10(@lynx-js/lynx-core@0.1.3)(@lynx-js/react@0.116.5(@lynx-js/types@3.7.0)(@types/react@19.2.14))(tslib@2.8.1)(webpack@5.105.0(esbuild@0.27.7))': + dependencies: + '@lynx-js/css-extract-webpack-plugin': 0.7.0(@lynx-js/template-webpack-plugin@0.10.5(@lynx-js/lynx-core@0.1.3)(tslib@2.8.1))(webpack@5.105.0(esbuild@0.27.7)) + '@lynx-js/react-alias-rsbuild-plugin': 0.12.10 + '@lynx-js/react-refresh-webpack-plugin': 0.3.4(@lynx-js/react-webpack-plugin@0.7.4(@lynx-js/react@0.116.5(@lynx-js/types@3.7.0)(@types/react@19.2.14))(@lynx-js/template-webpack-plugin@0.10.5(@lynx-js/lynx-core@0.1.3)(tslib@2.8.1))) + '@lynx-js/react-webpack-plugin': 0.7.4(@lynx-js/react@0.116.5(@lynx-js/types@3.7.0)(@types/react@19.2.14))(@lynx-js/template-webpack-plugin@0.10.5(@lynx-js/lynx-core@0.1.3)(tslib@2.8.1)) + '@lynx-js/runtime-wrapper-webpack-plugin': 0.1.3 + '@lynx-js/template-webpack-plugin': 0.10.5(@lynx-js/lynx-core@0.1.3)(tslib@2.8.1) + '@lynx-js/use-sync-external-store': 1.5.0(@lynx-js/react@0.116.5(@lynx-js/types@3.7.0)(@types/react@19.2.14)) + background-only: 0.0.1 + optionalDependencies: + '@lynx-js/react': 0.116.5(@lynx-js/types@3.7.0)(@types/react@19.2.14) + transitivePeerDependencies: + - '@lynx-js/lynx-core' + - tslib + - webpack + '@lynx-js/react-webpack-plugin@0.7.4(@lynx-js/react@0.116.5(@lynx-js/types@3.7.0)(@types/react@18.3.28))(@lynx-js/template-webpack-plugin@0.10.5(@lynx-js/lynx-core@0.1.3)(tslib@2.8.1))': dependencies: '@lynx-js/template-webpack-plugin': 0.10.5(@lynx-js/lynx-core@0.1.3)(tslib@2.8.1) @@ -6824,6 +7080,14 @@ snapshots: optionalDependencies: '@lynx-js/react': 0.116.5(@lynx-js/types@3.7.0)(@types/react@18.3.28) + '@lynx-js/react-webpack-plugin@0.7.4(@lynx-js/react@0.116.5(@lynx-js/types@3.7.0)(@types/react@19.2.14))(@lynx-js/template-webpack-plugin@0.10.5(@lynx-js/lynx-core@0.1.3)(tslib@2.8.1))': + dependencies: + '@lynx-js/template-webpack-plugin': 0.10.5(@lynx-js/lynx-core@0.1.3)(tslib@2.8.1) + '@lynx-js/webpack-runtime-globals': 0.0.6 + tiny-invariant: 1.3.3 + optionalDependencies: + '@lynx-js/react': 0.116.5(@lynx-js/types@3.7.0)(@types/react@19.2.14) + '@lynx-js/react@0.116.5(@lynx-js/types@3.7.0)(@types/react@18.3.28)': dependencies: '@types/react': 18.3.28 @@ -6831,6 +7095,39 @@ snapshots: optionalDependencies: '@lynx-js/types': 3.7.0 + '@lynx-js/react@0.116.5(@lynx-js/types@3.7.0)(@types/react@19.2.14)': + dependencies: + '@types/react': 19.2.14 + preact: '@hongzhiyuan/preact@10.28.0-fc4af453' + optionalDependencies: + '@lynx-js/types': 3.7.0 + + '@lynx-js/rspeedy@0.13.6(@rspack/core@1.7.11(@swc/helpers@0.5.21))(esbuild@0.27.7)(typescript@5.9.3)(webpack@5.105.0(esbuild@0.27.7))': + dependencies: + '@lynx-js/cache-events-webpack-plugin': 0.0.3 + '@lynx-js/chunk-loading-webpack-plugin': 0.3.3 + '@lynx-js/web-rsbuild-server-middleware': 0.19.9 + '@lynx-js/webpack-dev-transport': 0.2.0 + '@lynx-js/websocket': 0.0.4 + '@rsbuild/core': 1.7.3 + '@rsbuild/plugin-css-minimizer': 1.1.1(@rsbuild/core@1.7.3)(esbuild@0.27.7)(webpack@5.105.0(esbuild@0.27.7)) + '@rsdoctor/rspack-plugin': 1.2.3(@rsbuild/core@1.7.3)(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.105.0(esbuild@0.27.7)) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@parcel/css' + - '@rspack/core' + - '@swc/css' + - bufferutil + - clean-css + - csso + - debug + - esbuild + - lightningcss + - supports-color + - utf-8-validate + - webpack + '@lynx-js/rspeedy@0.13.6(@rspack/core@1.7.11(@swc/helpers@0.5.21))(typescript@5.9.3)(webpack@5.105.0)': dependencies: '@lynx-js/cache-events-webpack-plugin': 0.0.3 @@ -6884,6 +7181,10 @@ snapshots: dependencies: '@lynx-js/react': 0.116.5(@lynx-js/types@3.7.0)(@types/react@18.3.28) + '@lynx-js/use-sync-external-store@1.5.0(@lynx-js/react@0.116.5(@lynx-js/types@3.7.0)(@types/react@19.2.14))': + dependencies: + '@lynx-js/react': 0.116.5(@lynx-js/types@3.7.0)(@types/react@19.2.14) + '@lynx-js/web-core-wasm@0.0.5(@lynx-js/css-serializer@0.1.4)(@lynx-js/lynx-core@0.1.3)(tslib@2.8.1)': dependencies: '@lynx-js/web-elements': 0.12.0(tslib@2.8.1) @@ -7191,6 +7492,21 @@ snapshots: optionalDependencies: '@rsbuild/core': 1.7.3 + '@rsbuild/plugin-css-minimizer@1.1.1(@rsbuild/core@1.7.3)(esbuild@0.27.7)(webpack@5.105.0(esbuild@0.27.7))': + dependencies: + css-minimizer-webpack-plugin: 7.0.2(esbuild@0.27.7)(webpack@5.105.0(esbuild@0.27.7)) + reduce-configs: 1.1.2 + optionalDependencies: + '@rsbuild/core': 1.7.3 + transitivePeerDependencies: + - '@parcel/css' + - '@swc/css' + - clean-css + - csso + - esbuild + - lightningcss + - webpack + '@rsbuild/plugin-css-minimizer@1.1.1(@rsbuild/core@1.7.3)(webpack@5.105.0)': dependencies: css-minimizer-webpack-plugin: 7.0.2(webpack@5.105.0) @@ -7227,6 +7543,31 @@ snapshots: '@rsdoctor/client@1.2.3': {} + '@rsdoctor/core@1.2.3(@rsbuild/core@1.7.3)(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.105.0(esbuild@0.27.7))': + dependencies: + '@rsbuild/plugin-check-syntax': 1.3.0(@rsbuild/core@1.7.3) + '@rsdoctor/graph': 1.2.3(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.105.0(esbuild@0.27.7)) + '@rsdoctor/sdk': 1.2.3(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.105.0(esbuild@0.27.7)) + '@rsdoctor/types': 1.2.3(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.105.0(esbuild@0.27.7)) + '@rsdoctor/utils': 1.2.3(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.105.0(esbuild@0.27.7)) + axios: 1.15.0 + browserslist-load-config: 1.0.1 + enhanced-resolve: 5.12.0 + filesize: 10.1.6 + fs-extra: 11.3.4 + lodash: 4.18.1 + path-browserify: 1.0.1 + semver: 7.7.4 + source-map: 0.7.6 + transitivePeerDependencies: + - '@rsbuild/core' + - '@rspack/core' + - bufferutil + - debug + - supports-color + - utf-8-validate + - webpack + '@rsdoctor/core@1.2.3(@rsbuild/core@1.7.3)(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.105.0)': dependencies: '@rsbuild/plugin-check-syntax': 1.3.0(@rsbuild/core@1.7.3) @@ -7252,6 +7593,17 @@ snapshots: - utf-8-validate - webpack + '@rsdoctor/graph@1.2.3(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.105.0(esbuild@0.27.7))': + dependencies: + '@rsdoctor/types': 1.2.3(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.105.0(esbuild@0.27.7)) + '@rsdoctor/utils': 1.2.3(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.105.0(esbuild@0.27.7)) + lodash.unionby: 4.8.0 + source-map: 0.7.6 + transitivePeerDependencies: + - '@rspack/core' + - supports-color + - webpack + '@rsdoctor/graph@1.2.3(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.105.0)': dependencies: '@rsdoctor/types': 1.2.3(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.105.0) @@ -7263,6 +7615,24 @@ snapshots: - supports-color - webpack + '@rsdoctor/rspack-plugin@1.2.3(@rsbuild/core@1.7.3)(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.105.0(esbuild@0.27.7))': + dependencies: + '@rsdoctor/core': 1.2.3(@rsbuild/core@1.7.3)(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.105.0(esbuild@0.27.7)) + '@rsdoctor/graph': 1.2.3(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.105.0(esbuild@0.27.7)) + '@rsdoctor/sdk': 1.2.3(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.105.0(esbuild@0.27.7)) + '@rsdoctor/types': 1.2.3(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.105.0(esbuild@0.27.7)) + '@rsdoctor/utils': 1.2.3(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.105.0(esbuild@0.27.7)) + lodash: 4.18.1 + optionalDependencies: + '@rspack/core': 1.7.11(@swc/helpers@0.5.21) + transitivePeerDependencies: + - '@rsbuild/core' + - bufferutil + - debug + - supports-color + - utf-8-validate + - webpack + '@rsdoctor/rspack-plugin@1.2.3(@rsbuild/core@1.7.3)(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.105.0)': dependencies: '@rsdoctor/core': 1.2.3(@rsbuild/core@1.7.3)(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.105.0) @@ -7281,6 +7651,30 @@ snapshots: - utf-8-validate - webpack + '@rsdoctor/sdk@1.2.3(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.105.0(esbuild@0.27.7))': + dependencies: + '@rsdoctor/client': 1.2.3 + '@rsdoctor/graph': 1.2.3(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.105.0(esbuild@0.27.7)) + '@rsdoctor/types': 1.2.3(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.105.0(esbuild@0.27.7)) + '@rsdoctor/utils': 1.2.3(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.105.0(esbuild@0.27.7)) + '@types/fs-extra': 11.0.4 + body-parser: 1.20.3 + cors: 2.8.5 + dayjs: 1.11.13 + fs-extra: 11.3.4 + json-cycle: 1.5.0 + open: 8.4.2 + sirv: 2.0.4 + socket.io: 4.8.1 + source-map: 0.7.6 + tapable: 2.2.2 + transitivePeerDependencies: + - '@rspack/core' + - bufferutil + - supports-color + - utf-8-validate + - webpack + '@rsdoctor/sdk@1.2.3(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.105.0)': dependencies: '@rsdoctor/client': 1.2.3 @@ -7305,6 +7699,16 @@ snapshots: - utf-8-validate - webpack + '@rsdoctor/types@1.2.3(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.105.0(esbuild@0.27.7))': + dependencies: + '@types/connect': 3.4.38 + '@types/estree': 1.0.5 + '@types/tapable': 2.2.7 + source-map: 0.7.6 + optionalDependencies: + '@rspack/core': 1.7.11(@swc/helpers@0.5.21) + webpack: 5.105.0(esbuild@0.27.7) + '@rsdoctor/types@1.2.3(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.105.0)': dependencies: '@types/connect': 3.4.38 @@ -7315,6 +7719,30 @@ snapshots: '@rspack/core': 1.7.11(@swc/helpers@0.5.21) webpack: 5.105.0 + '@rsdoctor/utils@1.2.3(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.105.0(esbuild@0.27.7))': + dependencies: + '@babel/code-frame': 7.26.2 + '@rsdoctor/types': 1.2.3(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.105.0(esbuild@0.27.7)) + '@types/estree': 1.0.5 + acorn: 8.16.0 + acorn-import-attributes: 1.9.5(acorn@8.16.0) + acorn-walk: 8.3.4 + connect: 3.7.0 + deep-eql: 4.1.4 + envinfo: 7.14.0 + filesize: 10.1.6 + fs-extra: 11.3.4 + get-port: 5.1.1 + json-stream-stringify: 3.0.1 + lines-and-columns: 2.0.4 + picocolors: 1.1.1 + rslog: 1.3.2 + strip-ansi: 6.0.1 + transitivePeerDependencies: + - '@rspack/core' + - supports-color + - webpack + '@rsdoctor/utils@1.2.3(@rspack/core@1.7.11(@swc/helpers@0.5.21))(webpack@5.105.0)': dependencies: '@babel/code-frame': 7.26.2 @@ -7702,6 +8130,94 @@ snapshots: dependencies: tslib: 2.8.1 + '@tanstack/history@1.162.0': {} + + '@tanstack/react-router@1.170.17(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@tanstack/history': 1.162.0 + '@tanstack/react-store': 0.9.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@tanstack/router-core': 1.171.14 + isbot: 5.2.1 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + + '@tanstack/react-store@0.9.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@tanstack/store': 0.9.3 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + use-sync-external-store: 1.6.0(react@19.2.5) + + '@tanstack/router-core@1.171.14': + dependencies: + '@tanstack/history': 1.162.0 + cookie-es: 3.1.1 + seroval: 1.5.6 + seroval-plugins: 1.5.6(seroval@1.5.6) + + '@tanstack/router-core@1.171.15': + dependencies: + '@tanstack/history': 1.162.0 + cookie-es: 3.1.1 + seroval: 1.5.6 + seroval-plugins: 1.5.6(seroval@1.5.6) + + '@tanstack/router-generator@1.167.21': + dependencies: + '@babel/types': 7.29.0 + '@tanstack/router-core': 1.171.15 + '@tanstack/router-utils': 1.162.2 + '@tanstack/virtual-file-routes': 1.162.0 + jiti: 2.7.0 + magic-string: 0.30.21 + prettier: 3.8.3 + zod: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@tanstack/router-plugin@1.168.23(@rsbuild/core@1.7.3)(@rspack/core@1.7.11(@swc/helpers@0.5.21))(@tanstack/react-router@1.170.17(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(esbuild@0.27.7)(rollup@4.60.1)(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))(webpack@5.105.0(esbuild@0.27.7))': + dependencies: + '@babel/core': 7.29.0 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + '@tanstack/router-core': 1.171.15 + '@tanstack/router-generator': 1.167.21 + '@tanstack/router-utils': 1.162.2 + chokidar: 5.0.0 + unplugin: 3.3.0(@rspack/core@1.7.11(@swc/helpers@0.5.21))(esbuild@0.27.7)(rollup@4.60.1)(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))(webpack@5.105.0(esbuild@0.27.7)) + zod: 4.4.3 + optionalDependencies: + '@rsbuild/core': 1.7.3 + '@tanstack/react-router': 1.170.17(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + 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) + webpack: 5.105.0(esbuild@0.27.7) + transitivePeerDependencies: + - '@farmfe/core' + - '@rspack/core' + - bun-types-no-globals + - esbuild + - rolldown + - rollup + - supports-color + - unloader + + '@tanstack/router-utils@1.162.2': + dependencies: + '@babel/generator': 7.29.1 + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + ansis: 4.3.1 + babel-dead-code-elimination: 1.0.12 + diff: 8.0.4 + pathe: 2.0.3 + tinyglobby: 0.2.16 + transitivePeerDependencies: + - supports-color + + '@tanstack/store@0.9.3': {} + + '@tanstack/virtual-file-routes@1.162.0': {} + '@testing-library/jest-dom@6.9.1': dependencies: '@adobe/css-tools': 4.4.4 @@ -8085,7 +8601,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.6.1)(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)(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 @@ -8100,7 +8616,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.6.1)(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)(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 @@ -8116,7 +8632,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.6.1)(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)(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: @@ -8135,21 +8651,29 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@3.2.4(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(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@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 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@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) - '@vitest/mocker@4.1.4(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3))': + '@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))': dependencies: '@vitest/spy': 4.1.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@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) '@vitest/pretty-format@3.2.4': dependencies: @@ -8342,6 +8866,8 @@ snapshots: ansi-styles@6.2.3: {} + ansis@4.3.1: {} + anymatch@3.1.3: dependencies: normalize-path: 3.0.0 @@ -8406,6 +8932,15 @@ snapshots: transitivePeerDependencies: - debug + babel-dead-code-elimination@1.0.12: + dependencies: + '@babel/core': 7.29.0 + '@babel/parser': 7.29.2 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + babel-jest@29.7.0(@babel/core@7.29.0): dependencies: '@babel/core': 7.29.0 @@ -8611,7 +9146,6 @@ snapshots: chokidar@5.0.0: dependencies: readdirp: 5.0.0 - optional: true chrome-trace-event@1.0.4: {} @@ -8674,6 +9208,8 @@ snapshots: convert-source-map@2.0.0: {} + cookie-es@3.1.1: {} + cookie@0.7.2: {} cookie@1.1.1: {} @@ -8733,6 +9269,18 @@ snapshots: dependencies: postcss: 8.5.10 + css-minimizer-webpack-plugin@7.0.2(esbuild@0.27.7)(webpack@5.105.0(esbuild@0.27.7)): + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + cssnano: 7.1.5(postcss@8.5.10) + jest-worker: 29.7.0 + postcss: 8.5.10 + schema-utils: 4.3.3 + serialize-javascript: 6.0.2 + webpack: 5.105.0(esbuild@0.27.7) + optionalDependencies: + esbuild: 0.27.7 + css-minimizer-webpack-plugin@7.0.2(webpack@5.105.0): dependencies: '@jridgewell/trace-mapping': 0.3.31 @@ -8934,6 +9482,8 @@ snapshots: diff@4.0.4: {} + diff@8.0.4: {} + dom-accessibility-api@0.6.3: {} dom-serializer@2.0.0: @@ -9781,6 +10331,8 @@ snapshots: isarray@2.0.5: {} + isbot@5.2.1: {} + isexe@2.0.0: {} istanbul-lib-coverage@3.2.2: {} @@ -10248,6 +10800,8 @@ snapshots: jiti@2.6.1: {} + jiti@2.7.0: {} + js-tokens@10.0.0: {} js-tokens@4.0.0: {} @@ -10920,6 +11474,12 @@ snapshots: min-indent@1.0.1: {} + mini-css-extract-plugin@2.10.2(webpack@5.105.0(esbuild@0.27.7)): + dependencies: + schema-utils: 4.3.3 + tapable: 2.3.2 + webpack: 5.105.0(esbuild@0.27.7) + mini-css-extract-plugin@2.10.2(webpack@5.105.0): dependencies: schema-utils: 4.3.3 @@ -11492,8 +12052,7 @@ snapshots: react@19.2.5: {} - readdirp@5.0.0: - optional: true + readdirp@5.0.0: {} recma-build-jsx@1.0.0: dependencies: @@ -11870,6 +12429,12 @@ snapshots: dependencies: randombytes: 2.1.0 + seroval-plugins@1.5.6(seroval@1.5.6): + dependencies: + seroval: 1.5.6 + + seroval@1.5.6: {} + set-cookie-parser@2.7.2: {} set-function-length@1.2.2: @@ -12195,6 +12760,16 @@ snapshots: tapable@2.3.2: {} + terser-webpack-plugin@5.4.0(esbuild@0.27.7)(webpack@5.105.0(esbuild@0.27.7)): + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + jest-worker: 27.5.1 + schema-utils: 4.3.3 + terser: 5.46.1 + webpack: 5.105.0(esbuild@0.27.7) + optionalDependencies: + esbuild: 0.27.7 + terser-webpack-plugin@5.4.0(webpack@5.105.0): dependencies: '@jridgewell/trace-mapping': 0.3.31 @@ -12499,6 +13074,18 @@ snapshots: unpipe@1.0.0: {} + unplugin@3.3.0(@rspack/core@1.7.11(@swc/helpers@0.5.21))(esbuild@0.27.7)(rollup@4.60.1)(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))(webpack@5.105.0(esbuild@0.27.7)): + dependencies: + '@jridgewell/remapping': 2.3.5 + picomatch: 4.0.4 + webpack-virtual-modules: 0.6.2 + optionalDependencies: + '@rspack/core': 1.7.11(@swc/helpers@0.5.21) + esbuild: 0.27.7 + rollup: 4.60.1 + 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) + webpack: 5.105.0(esbuild@0.27.7) + update-browserslist-db@1.2.3(browserslist@4.28.2): dependencies: browserslist: 4.28.2 @@ -12542,13 +13129,13 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite-node@3.2.4(@types/node@25.6.0)(jiti@2.6.1)(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): 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@25.6.0)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.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 @@ -12563,7 +13150,45 @@ snapshots: - tsx - yaml - vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3): + 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 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.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) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - 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.10 + 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) @@ -12574,17 +13199,60 @@ snapshots: optionalDependencies: '@types/node': 25.6.0 fsevents: 2.3.3 - jiti: 2.6.1 + jiti: 2.7.0 sass: 1.100.0 sass-embedded: 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)(jiti@2.6.1)(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)(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 + 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)(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 - '@vitest/mocker': 3.2.4(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(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)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -12602,8 +13270,8 @@ snapshots: tinyglobby: 0.2.16 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3) - vite-node: 3.2.4(@types/node@25.6.0)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@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) + 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) why-is-node-running: 2.3.0 optionalDependencies: '@types/debug': 4.1.13 @@ -12623,10 +13291,53 @@ 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.6.1)(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)(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@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/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@25.6.0)(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@25.6.0)(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': 25.6.0 + jsdom: 28.1.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - 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)): dependencies: '@vitest/expect': 4.1.4 - '@vitest/mocker': 4.1.4(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.46.1)(yaml@2.8.3)) + '@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)) '@vitest/pretty-format': 4.1.4 '@vitest/runner': 4.1.4 '@vitest/snapshot': 4.1.4 @@ -12643,7 +13354,7 @@ snapshots: tinyexec: 1.1.1 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vite: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@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) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.6.0 @@ -12681,6 +13392,8 @@ snapshots: webpack-sources@3.3.4: {} + webpack-virtual-modules@0.6.2: {} + webpack@5.105.0: dependencies: '@types/eslint-scope': 3.7.7 @@ -12713,6 +13426,38 @@ snapshots: - esbuild - uglify-js + webpack@5.105.0(esbuild@0.27.7): + dependencies: + '@types/eslint-scope': 3.7.7 + '@types/estree': 1.0.8 + '@types/json-schema': 7.0.15 + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/wasm-edit': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + acorn: 8.16.0 + acorn-import-phases: 1.0.4(acorn@8.16.0) + browserslist: 4.28.2 + chrome-trace-event: 1.0.4 + enhanced-resolve: 5.20.1 + es-module-lexer: 2.0.0 + eslint-scope: 5.1.1 + events: 3.3.0 + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.11 + json-parse-even-better-errors: 2.3.1 + loader-runner: 4.3.1 + mime-types: 2.1.35 + neo-async: 2.6.2 + schema-utils: 4.3.3 + tapable: 2.3.2 + terser-webpack-plugin: 5.4.0(esbuild@0.27.7)(webpack@5.105.0(esbuild@0.27.7)) + watchpack: 2.5.1 + webpack-sources: 3.3.4 + transitivePeerDependencies: + - '@swc/core' + - esbuild + - uglify-js + whatwg-encoding@3.1.1: dependencies: iconv-lite: 0.6.3 @@ -12835,4 +13580,6 @@ snapshots: yocto-queue@0.1.0: {} + zod@4.4.3: {} + zwitch@2.0.4: {}