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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ API.

- **adaptv and `create-adaptv` are MIT-licensed.** They were `UNLICENSED`. `LICENSE` is at the repo
root and in `packages/create-adaptv`.
- **The router moves to its release with the preload fix** (TanStack Router 1.170.19, router core
1.171.16, Start 1.168.36), and adaptv's two in-memory engine edits are pinned to the matching
versions. See Fixed.
- **Hovers and type errors show adaptv's type names, not the router's.** This covers `notFoundScreen`
and `notFoundComponent` (`NotFoundScreenComponent`, `NotFoundScreenProps`), `notFound`
(`NotFoundOptions`), `isRedirect` (`RouteRedirect`), the route DSL (`RouteNode`,
Expand All @@ -38,6 +41,12 @@ API.
default without `!important`. A selector that targeted one of adaptv's Tailwind classes no longer
matches; target the `data-adaptv` / `data-part` attributes instead.

### Fixed

- **Tapping a link while its route is still preloading no longer logs a `TypeError`.** The tap
navigated mid-preload; the navigation evicted the preloaded route from the cache, and the preload
then read the evicted entry (`match._nonReactive`). The navigation itself always worked.

## 0.1.0-alpha.1

The first public alpha. Not published to npm yet: publishing waits on approval, and until then the
Expand Down
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -144,10 +144,10 @@
"@capacitor/splash-screen": "8.0.2",
"@capacitor/status-bar": "8.0.2",
"@capawesome/capacitor-live-update": "8.3.0",
"@tanstack/react-router": "1.170.18",
"@tanstack/react-start": "1.168.32",
"@tanstack/react-router": "1.170.19",
"@tanstack/react-start": "1.168.36",
"@tanstack/react-virtual": "3.14.6",
"@tanstack/router-core": "1.171.15",
"@tanstack/router-core": "1.171.16",
"@tanstack/virtual-file-routes": "1.162.0",
"@vitejs/plugin-react": "5.2.0",
"commander": "15.0.0",
Expand Down
10 changes: 1 addition & 9 deletions playground/e2e/capabilities-smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,7 @@ import { awaitClientHandover } from "./support/hydrated"
/**
* Console errors this spec does NOT fail on. Each entry is a filed, understood
* error with a reason; there is no wildcard, and a new error is a failure until
* someone writes its line here. Empty on purpose: the one known handled error in
* the app — TanStack Router's preload path throwing `_nonReactive` on a
* react-router/router-core version skew (see link.spec.ts) — fires from a link
* being hovered or tapped, which nothing here does: a full run on both engines
* and six repeats of this spec never saw it on these routes. It is not excused
* ahead of time; if it ever shows up here the failure names it, and the line is
* one entry — with the follow-on the debug telemetry adds when it forwards that
* console.error to a log sink that is not running, which is why an entry that
* matched only the TypeError would not even make the test pass.
* someone writes its line here. Empty on purpose.
*/
const KNOWN_CONSOLE_ERRORS: { pattern: RegExp; reason: string }[] = []

Expand Down
4 changes: 1 addition & 3 deletions playground/e2e/link.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,7 @@ import { awaitClientHandover } from "./support/hydrated"
* pop-vs-push. Those fire through the shared gesture engine, which — exactly like
* Checkbox/Switch — does not respond to Playwright's synthetic mouse/keyboard/
* dispatched input; only a real finger or the OS drives it, so they are walked on
* the simulator. (Separately: TanStack Router's PRELOAD path logs `_nonReactive`
* here from a react-router/router-core version skew. It is caught and non-fatal,
* orthogonal to these two components, and flagged for its own cleanup.)
* the simulator.
*/

test.describe("Link & ExternalLink", () => {
Expand Down
155 changes: 77 additions & 78 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions src/execution-boundary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ const CONSUMER_BUILD_ENTRIES = [
"src/routes/client-entry.tsx",
"src/routes/router-entry.tsx",
"src/components/boot-error.tsx",
"src/routes/lazy-route-component.ts",
"src/sw/default-worker.ts",
]

Expand Down
83 changes: 83 additions & 0 deletions src/routes/lazy-route-component.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { afterEach, describe, expect, it, vi } from "vitest"
import { lazyRouteComponent } from "#adaptv/routes/lazy-route-component.ts"

/*
* A split route whose chunk is gone reloads the page once. React renders the suspended
* route more than once before the page goes, and with react-router 1.170.19's
* `lazyRouteComponent` the second render threw the import error, so the app's error
* screen drew over the reload. The end-to-end proof is `e2e-sw/stale-chunk.spec.ts`.
*/

const MISSING = new TypeError(
"Failed to fetch dynamically imported module: /assets/home-old.js",
)

afterEach(() => {
sessionStorage.clear()
vi.restoreAllMocks()
})

function stubReload() {
const reload = vi.fn()
vi.spyOn(window, "location", "get").mockReturnValue({
...window.location,
reload,
})
return reload
}

describe("lazyRouteComponent", () => {
it("suspends every render after a missing chunk, until the reload lands", async () => {
const reload = stubReload()
const Lazy = lazyRouteComponent(() => Promise.reject(MISSING))
await Lazy.preload()

for (let render = 0; render < 3; render++) {
let thrown: unknown
try {
Lazy({})
} catch (value) {
thrown = value
}
expect(thrown).toBeInstanceOf(Promise)
}
expect(reload).toHaveBeenCalled()
expect(
sessionStorage.getItem(`tanstack_router_reload:${MISSING.message}`),
).toBe("1")
})

it("throws the error when the reload already happened once for that chunk", async () => {
const reload = stubReload()
sessionStorage.setItem(
`tanstack_router_reload:${MISSING.message}`,
"1",
)
const Lazy = lazyRouteComponent(() => Promise.reject(MISSING))
await Lazy.preload()

expect(() => Lazy({})).toThrow(MISSING)
expect(reload).not.toHaveBeenCalled()
})

it("throws any other import error without reloading", async () => {
const reload = stubReload()
const failure = new Error("boom")
const Lazy = lazyRouteComponent(() => Promise.reject(failure))
await Lazy.preload()

expect(() => Lazy({})).toThrow(failure)
expect(reload).not.toHaveBeenCalled()
})

it("renders the named export once the chunk has loaded", async () => {
const Page = () => null
const Lazy = lazyRouteComponent(
() => Promise.resolve({ Page }),
"Page",
)
await Lazy.preload()

expect(Lazy({})).toMatchObject({ type: Page })
})
})
80 changes: 80 additions & 0 deletions src/routes/lazy-route-component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import type { ComponentType } from "react"
import { createElement, use } from "react"

/**
* The `lazyRouteComponent` TanStack's code-splitter writes into every split route of the
* app. `tanstack-resolve.ts` serves the app this one in place of the router's own.
*
* A route whose chunk is gone (the deploy moved on under an open tab) reloads the page
* once, guarded by a `tanstack_router_reload:` sessionStorage key so a chunk that is
* really missing does not loop. The decision is LATCHED when the import fails: every
* render after it suspends until the reload lands.
*
* react-router 1.170.19 moved the decision into the render: the first render sets the key
* and reloads, and the next one, before the page has gone, finds the key set and throws
* the import error to the route's error boundary. React renders a suspended route more
* than once, so the app's error screen drew over the reload, and `stale-chunk.spec.ts`
* failed on both engines. That release is also the first with the fix for
* TanStack/router#7759 (a preload reading a match a navigation had just evicted), so
* adaptv keeps the router and owns this one function, as 1.170.18 shipped it.
*/
export function lazyRouteComponent<
TModule extends Record<string, unknown>,
TKey extends keyof TModule = "default",
>(importer: () => Promise<TModule>, exportName?: TKey) {
let loadPromise: Promise<void> | undefined
let comp: ComponentType<object> | undefined
let error: unknown
let reload = false

const load = () => {
if (!loadPromise)
loadPromise = importer()
.then((res) => {
loadPromise = undefined
comp = res[exportName ?? "default"] as ComponentType<object>
})
.catch((err: unknown) => {
error = err
if (
isModuleNotFoundError(err) &&
typeof window !== "undefined" &&
typeof sessionStorage !== "undefined"
) {
const storageKey = `tanstack_router_reload:${err.message}`
if (!sessionStorage.getItem(storageKey)) {
sessionStorage.setItem(storageKey, "1")
reload = true
}
}
})
return loadPromise
}

const lazyComp = function Lazy(props: object) {
if (reload) {
window.location.reload()
throw new Promise(() => {})
}
if (error) throw error
if (!comp) use(load())
return createElement(comp as ComponentType<object>, props)
}
lazyComp.preload = load
return lazyComp
}

//router-core's `isModuleNotFoundError`, copied: router-core is not adaptv's dependency.
//The three engines' messages for a dynamic import that failed to fetch.
function isModuleNotFoundError(error: unknown): error is Error {
if (!(error instanceof Error)) return false
return (
error.message.startsWith(
"Failed to fetch dynamically imported module",
) ||
error.message.startsWith(
"error loading dynamically imported module",
) ||
error.message.startsWith("Importing a module script failed")
)
}
52 changes: 52 additions & 0 deletions src/shell/create-adaptv-router.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import {
createLazyRoute,
createMemoryHistory,
createRootRoute,
createRoute,
} from "@tanstack/react-router"
import { afterEach, describe, expect, it, vi } from "vitest"
import { createAdaptvRouter } from "#adaptv/shell/create-adaptv-router"

afterEach(() => {
vi.restoreAllMocks()
})

describe("createAdaptvRouter", () => {
//A link preloads its route on intent (hover, touchstart), and the tap that follows
//navigates while that preload is still in flight. The navigation's commit clears
//the expired cache, which drops the cached match of a route with no loader, so the
//preload came back to a match that was gone and threw `match._nonReactive` (the
//route engine caught it and console.error'd it). Seen ~1 in 8 on webkit in
//playground/e2e/icon.spec.ts, the StrictMode client-navigation test.
it("a navigation that commits during a preload leaves the preload to finish quietly", async () => {
let release = () => {}
const chunk = new Promise<void>((resolve) => {
release = resolve
})
const rootRoute = createRootRoute()
const routeTree = rootRoute.addChildren([
createRoute({ getParentRoute: () => rootRoute, path: "/" }),
//code-split like every app route, and no loader: its cached match is the one
//a commit evicts while the chunk is still loading
createRoute({
getParentRoute: () => rootRoute,
path: "/preloaded",
}).lazy(() => chunk.then(() => createLazyRoute("/preloaded")({}))),
createRoute({ getParentRoute: () => rootRoute, path: "/elsewhere" }),
])
const router = createAdaptvRouter({
routeTree,
options: { history: createMemoryHistory({ initialEntries: ["/"] }) },
})
await router.load()
const error = vi.spyOn(console, "error").mockImplementation(() => {})

const preload = router.preloadRoute({ to: "/preloaded" })
await router.navigate({ to: "/elsewhere" })
release()
await preload

expect(router.state.location.pathname).toBe("/elsewhere")
expect(error).not.toHaveBeenCalled()
})
})
6 changes: 3 additions & 3 deletions src/shell/shell-layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -173,12 +173,12 @@ type RoutingShellProps = {
*/
function useNotFoundBoundary() {
return useRouterState({
//`globalNotFound` marks the boundary that renders the not-found; `notFound`
//`_notFound` marks the boundary that renders the not-found; `notFound`
//status is the nested (`notFoundMode: "fuzzy"`) form of the same thing. Both
//cut the tree off below that point.
select: (state) =>
state.matches.some(
(match) => match.globalNotFound || match.status === "notFound",
(match) => match._notFound || match.status === "notFound",
),
})
}
Expand Down Expand Up @@ -287,7 +287,7 @@ export function RoutingShell({
//
//adaptv mounts the splash, so adaptv retires it — there is no boot left to cover
//when the app tree is never going to mount. During render, not in an effect, so
//the SSR pass and hydration agree (`globalNotFound` is dehydrated) and a native
//the SSR pass and hydration agree (`_notFound` is dehydrated) and a native
//cold start never paints the splash even for a frame. Latched, because retiring
//is one-way: re-mounting on the navigation *out* of a 404 would hand a fresh
//splash an already-set ready gate and replay it over a booted app.
Expand Down
4 changes: 2 additions & 2 deletions src/vite/engine-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export const ENGINE_EDITS: readonly EngineEdit[] = [
//the import the generator writes into route files names the framework barrel
//(ADAPTV_ROUTER_PKG, set by adaptv's vite plugin). → src/vite/router-autoimport.ts
pkg: "@tanstack/router-generator",
version: "1.167.21",
version: "1.167.22",
file: "dist/esm/transform/transform.js",
replace: [
[
Expand All @@ -67,7 +67,7 @@ export const ENGINE_EDITS: readonly EngineEdit[] = [
//`#tanstack-router-entry` stay: they are subpath imports declared in
//start-server-core's own `imports` map, and never reach the HTML.
pkg: "@tanstack/start-plugin-core",
version: "1.171.24",
version: "1.171.27",
file: "dist/esm/constants.js",
replace: [
[
Expand Down
11 changes: 8 additions & 3 deletions src/vite/package-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,10 @@ const PACKAGE_NAME = "@arrzdev/adaptv"
/**
* The modules adaptv hands to the consumer's own build, which compiles them as part of the
* app: Start's client and router entries, the root route the route DSL points at, the
* service worker esbuild bundles, and the boot screen prerendered into the shell. In a
* checkout they are source; in a published package each is its own tsdown entry, so the
* consumer gets the same file the framework's own entries share chunks with.
* service worker esbuild bundles, the boot screen prerendered into the shell, and the
* `lazyRouteComponent` the app's split routes import. In a checkout they are source; in a
* published package each is its own tsdown entry, so the consumer gets the same file the
* framework's own entries share chunks with.
* → tsdown.config.ts
*/
export const SHIPPED_FILES = {
Expand All @@ -48,6 +49,10 @@ export const SHIPPED_FILES = {
src: "src/components/boot-error.tsx",
dist: "dist/boot-error.mjs",
},
"lazy-route-component": {
src: "src/routes/lazy-route-component.ts",
dist: "dist/lazy-route-component.mjs",
},
} as const

export type ShippedFile = keyof typeof SHIPPED_FILES
Expand Down
Loading
Loading