Skip to content

Comments

Add initial preact integration#6700

Open
JoviDeCroock wants to merge 4 commits intoTanStack:mainfrom
JoviDeCroock:preact-integration
Open

Add initial preact integration#6700
JoviDeCroock wants to merge 4 commits intoTanStack:mainfrom
JoviDeCroock:preact-integration

Conversation

@JoviDeCroock
Copy link

@JoviDeCroock JoviDeCroock commented Feb 19, 2026

Summary

Note

The streaming implementation isn't battle tested in preact, we however do export renderToStringAsync which resolves promises in-place

Supersedes #5782

This PR completes the initial Preact support work in TanStack Router

  • Added a basic implementation mirrored from otheri ntegrations
  • Used the Suspense implementation from preact-suspense.
  • Used preact-suspense and preact-render-to-string as runtime dependencies for @tanstack/preact-router.
  • Added SSR entrypoints and handlers under packages/preact-router/src/ssr:
  • Added example project in examples/basic
  • Added new e2e project:
    • e2e/preact-router/basic
    • Playwright test asserts suspense fallback -> resolved content flow.

Summary by CodeRabbit

  • New Features

    • New Preact router package with full routing primitives, link/navigation APIs, suspense-based loading, error/not-found boundaries, scroll restoration, navigation blocking, and SSR support.
  • Documentation

    • Added a comprehensive basic Preact example with quick-start and usage docs.
  • Tests

    • Added extensive unit, integration, and E2E tests covering routing, links, loaders, suspense, blocker, and SSR flows.
  • Chores

    • New example/e2e projects, workspace entries, editor settings, and ignore files to streamline development and testing.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 19, 2026

📝 Walkthrough

Walkthrough

Adds a new @tanstack/preact-router package with full Preact integration: router core, route APIs, hooks, rendering/match components, SSR streaming/string renderers, head/script management, tests, example app, and e2e Playwright tests. Includes workspace and package configuration files.

Changes

Cohort / File(s) Summary
Package manifests & tooling
packages/preact-router/package.json, packages/preact-router/tsconfig.json, packages/preact-router/tsconfig.legacy.json, packages/preact-router/vite.config.ts, packages/preact-router/eslint.config.ts, package.json, pnpm-workspace.yaml
New package manifest, TS and Vite configs, ESLint overrides, workspace globs and pnpm override for @tanstack/preact-router.
Public API / barrel
packages/preact-router/src/index.tsx, packages/preact-router/src/index.dev.tsx
Comprehensive exports aggregator and a dev entrypoint overriding HeadContent for dev-mode behavior.
Router construction & context
packages/preact-router/src/router.ts, packages/preact-router/src/routerContext.tsx, packages/preact-router/src/RouterProvider.tsx
Router class/createRouter factory, RouterContext singleton, RouterContextProvider and RouterProvider components, module augmentation for Preact-specific options.
Route layer & lazy/file routes
packages/preact-router/src/route.tsx, packages/preact-router/src/fileRoute.ts, packages/preact-router/src/lazyRouteComponent.tsx
Route/RootRoute/NotFoundRoute types and factories, FileRoute and createFileRoute, createLazyRoute/createLazyFileRoute, and lazy route component loader with preload/reload behavior.
Matching & rendering
packages/preact-router/src/Match.tsx, packages/preact-router/src/Matches.tsx, packages/preact-router/src/matchContext.tsx
Match, MatchInner, Outlet, Matches components and associated match hooks (useMatchRoute, MatchRoute, useMatches, useParentMatches, useChildMatches) implementing nested render and boundary composition.
Head / Asset / Scripts / ClientOnly
packages/preact-router/src/Asset.tsx, packages/preact-router/src/Scripts.tsx, packages/preact-router/src/ScriptOnce.tsx, packages/preact-router/src/HeadContent.tsx, packages/preact-router/src/HeadContent.dev.tsx, packages/preact-router/src/headContentUtils.tsx, packages/preact-router/src/ClientOnly.tsx
SSR-aware head and body tag aggregation (useTags), Asset component with script dedupe/nonce handling, Scripts aggregation (manifest + route scripts), ScriptOnce for server-only inline scripts, dev-mode head cleanup, and ClientOnly/useHydrated helper.
SSR render & streaming
packages/preact-router/src/ssr/renderRouterToString.tsx, packages/preact-router/src/ssr/renderRouterToStream.tsx, packages/preact-router/src/ssr/RouterClient.tsx, packages/preact-router/src/ssr/RouterServer.tsx, packages/preact-router/src/ssr/defaultRenderHandler.tsx, packages/preact-router/src/ssr/defaultStreamHandler.tsx, packages/preact-router/src/ssr/serializer.ts, packages/preact-router/src/ssr/server.ts, packages/preact-router/src/ssr/client.ts
Server rendering utilities for string and stream modes, RouterServer/RouterClient wrappers for SSR hydration coordination, default handlers, and serializer augmentation for VNode buffering.
Hooks & router state
packages/preact-router/src/useRouter.tsx, packages/preact-router/src/useRouterState.tsx, packages/preact-router/src/useMatch.tsx, packages/preact-router/src/useLocation.tsx, packages/preact-router/src/useRouteContext.ts, packages/preact-router/src/useLoaderData.tsx, packages/preact-router/src/useLoaderDeps.tsx, packages/preact-router/src/useParams.tsx, packages/preact-router/src/useSearch.tsx, packages/preact-router/src/useCanGoBack.ts
Typed hooks for accessing router instance, reactive router state with optional structural sharing, match selection, location, route context, loader data/deps, params, search, and can-go-back check.
Navigation, Link & blocker
packages/preact-router/src/useNavigate.tsx, packages/preact-router/src/link.tsx, packages/preact-router/src/useBlocker.tsx, packages/preact-router/src/useCanGoBack.ts
useNavigate and Navigate component, full-featured Link with active state/preloading and SSR handling, useBlocker hook and Block component with resolver mode for conditional navigation blocking.
Error / NotFound / Boundaries
packages/preact-router/src/CatchBoundary.tsx, packages/preact-router/src/not-found.tsx, packages/preact-router/src/renderRouteNotFound.tsx
CatchBoundary with reset semantics and ErrorComponent; CatchNotFound and DefaultGlobalNotFound wrappers; utility to choose hierarchical not-found component.
Utilities, primitives & types
packages/preact-router/src/utils.ts, packages/preact-router/src/Transitioner.tsx, packages/preact-router/src/awaited.tsx, packages/preact-router/src/structuralSharing.ts, packages/preact-router/src/typePrimitives.ts, packages/preact-router/src/SafeFragment.tsx, packages/preact-router/src/Suspense.tsx, packages/preact-router/src/history.ts
Stable-callback, layout effect abstraction, previous value hook, IntersectionObserver helper, Transitioner lifecycle shim, Await/useAwaited utilities, structural sharing/type primitives, SafeFragment, Suspense re-export, history state augmentation.
Tests
packages/preact-router/tests/*
~20 unit/integration tests covering Matches, Link, loaders, navigation, blocking, params, SSR utilities, ClientOnly, Suspense, etc.
Examples & E2E
examples/preact/basic/..., e2e/preact-router/basic/...
Preact basic example app (routes, loaders, Tailwind, lazy posts) plus Playwright e2e test and Playwright config/setup for the example.
Misc config & ignores
examples/preact/basic/.vscode/settings.json, examples/preact/basic/README.md, examples/preact/basic/package.json, .../.gitignore, e2e/preact-router/basic/tsconfig.json, vite.config.js, index.html files
Example README, VSCode settings to exclude generated files, per-example package.json and Vite configs, and .gitignore additions for examples/e2e.

Sequence Diagram(s)

sequenceDiagram
  participant Client as Client (Browser)
  participant Router as Router (hydrate / runtime)
  participant SSR as Server (render)
  participant Stream as renderRouterToStream
  rect rgba(100,150,250,0.5)
  Client->>Router: initial load -> request page
  Router->>SSR: server render request (renderRouterToString / renderRouterToStream)
  SSR->>Stream: render VNode -> renderToStringAsync / renderToStream
  Stream->>SSR: buffered HTML / scripts (takeBufferedHtml / takeBufferedScripts)
  SSR->>Client: response HTML (with buffered scripts and nonce)
  Client->>Router: hydrate (RouterClient.hydrate)
  Router->>Router: apply buffered scripts, emit hydration finished
  end
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

Suggested reviewers

  • schiller-manuel
  • nlynzaad

Poem

🐰
I hopped through routes both near and far,
Built Links, SSR, and a lazy-loaded star.
Hooks and boundaries stitched with care,
Now Preact apps leap through the air!
Nibble a carrot — test suite passed! 🎉

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 45.31% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'Add initial preact integration' is a clear, concise summary of the main change: adding Preact support to TanStack Router.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 10

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

🟡 Minor comments (16)
examples/preact/basic/README.md-12-12 (1)

12-12: ⚠️ Potential issue | 🟡 Minor

gitpick path points to the React example instead of the Preact example.

-npx gitpick TanStack/router/tree/main/examples/react/basic basic
+npx gitpick TanStack/router/tree/main/examples/preact/basic basic
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/preact/basic/README.md` at line 12, The README's example clone
command points to the React example; update the gitpick path to the Preact
example by changing "TanStack/router/tree/main/examples/react/basic" to
"TanStack/router/tree/main/examples/preact/basic" so the npx gitpick command
pulls the correct Preact example.
examples/preact/basic/package.json-2-2 (1)

2-2: ⚠️ Potential issue | 🟡 Minor

Package name incorrectly identifies this as a React example.

-  "name": "tanstack-router-react-example-basic",
+  "name": "tanstack-router-preact-example-basic",
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/preact/basic/package.json` at line 2, The package.json "name" field
incorrectly labels this as a React example; update the "name" value in
examples/preact/basic/package.json (the "name" property) to reflect Preact
instead of React—e.g., change "tanstack-router-react-example-basic" to something
like "tanstack-router-preact-example-basic" (or another Preact-specific
identifier) so the package name accurately represents the Preact example.
packages/preact-router/src/ClientOnly.tsx-2-2 (1)

2-2: ⚠️ Potential issue | 🟡 Minor

Fix import sort order per ESLint.

ESLint reports that useEffect should precede useState alphabetically.

Proposed fix
-import { useState, useEffect } from 'preact/hooks'
+import { useEffect, useState } from 'preact/hooks'
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/preact-router/src/ClientOnly.tsx` at line 2, The import statement in
ClientOnly.tsx is failing ESLint's sort order because useEffect should come
before useState alphabetically; update the import from 'preact/hooks' so that
the named import list is alphabetized (useEffect, useState) to satisfy the
linter and keep the import sorted.
packages/preact-router/tests/ClientOnly.test.tsx-21-34 (1)

21-34: ⚠️ Potential issue | 🟡 Minor

Test doesn't verify what its name claims.

The test is named "renders fallback initially" but never asserts that 'Fallback' text appears. Since @testing-library/preact in jsdom flushes effects synchronously, the assertion on line 33 only checks the post-hydration state ('Client Content'), making this test functionally identical to the third test.

To actually test the fallback, you'd need to mock or suppress useEffect to observe the pre-hydration render. Otherwise, consider renaming this test to avoid confusion.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/preact-router/tests/ClientOnly.test.tsx` around lines 21 - 34, The
test named "renders fallback initially" is misleading because it never asserts
the fallback and effects run synchronously; either change the test to actually
assert the pre-hydration fallback by preventing useEffect from running (e.g.,
mock/suppress React/Preact's useEffect inside the test or wrap ClientOnly in a
stub that disables hydration effects) and then assert that container.textContent
contains 'Fallback', or simply rename the test to something like "renders client
content after hydration" to match the existing assertion; locate the ClientOnly
usage in the test and update the test setup or name accordingly.
packages/preact-router/src/Match.tsx-214-241 (1)

214-241: ⚠️ Potential issue | 🟡 Minor

Throwing undefined is possible if router.getMatch() returns undefined.

Lines 215, 219, and 240 all use optional chaining: router.getMatch(match.id)?._nonReactive.loadPromise. If getMatch returns undefined, throw undefined would propagate to the error boundary rather than suspending. While this shouldn't happen in practice (the match was just selected from state), this is a subtle failure mode.

A defensive invariant or fallback before the throw would make this more robust.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/preact-router/src/Match.tsx` around lines 214 - 241, The code may
throw undefined when calling router.getMatch(match.id)?._nonReactive.*; fix by
eagerly retrieving const routerMatch = router.getMatch(match.id) before any
throw and add a defensive check (e.g., invariant or throw new Error) if
routerMatch is undefined, then use
routerMatch._nonReactive.displayPendingPromise, .minPendingPromise or
.loadPromise for the throws and for setting minPendingPromise; update the
branches around match._displayPending, match._forcePending and match.status ===
'pending' to use this guarded routerMatch variable.
packages/preact-router/src/link.tsx-221-222 (1)

221-222: ⚠️ Potential issue | 🟡 Minor

includeHash check returns false unconditionally on the server path when hash is requested.

Line 221: if (activeOptions?.includeHash) return false — When the user sets includeHash: true, the server SSR path always treats the link as inactive because hash comparison isn't performed. The client path (line 401-403) at least compares s.location.hash === next.hash. Consider adding a comment clarifying this is intentional SSR behavior, or performing the hash comparison here.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/preact-router/src/link.tsx` around lines 221 - 222, The server-side
branch unconditionally returns false when activeOptions?.includeHash is true,
which makes SSR always treat hash-targeted links as inactive; update the server
path in link.tsx so that instead of immediately returning false it either (A)
documents this limitation with a clear comment stating includeHash cannot be
evaluated during SSR, or (B) performs a hash comparison similar to the client
path by checking available hash values (mirror the client check s.location.hash
=== next.hash against next.hash) when a current hash value is present; modify
the conditional using activeOptions?.includeHash to either add the explanatory
comment or replace the unconditional return with a guarded comparison to
correctly determine active state when a hash is available.
packages/preact-router/tests/link.test.tsx-12-15 (1)

12-15: ⚠️ Potential issue | 🟡 Minor

Remove unused import and mock variables.

getIntersectionObserverMock (line 12), ioObserveMock (line 14), and ioDisconnectMock (line 15) are defined but never used in any test. ESLint also flags the unused import.

Proposed fix
 import {
   Link,
   Outlet,
   RouterProvider,
   createMemoryHistory,
   createRootRoute,
   createRoute,
   createRouter,
 } from '../src'
-import { getIntersectionObserverMock } from './utils'
-
-const ioObserveMock = vi.fn()
-const ioDisconnectMock = vi.fn()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/preact-router/tests/link.test.tsx` around lines 12 - 15, Remove the
unused import and mocks: delete the getIntersectionObserverMock import and the
two unused mock constants ioObserveMock and ioDisconnectMock from the tests so
they are not declared but unused; locate the declarations by the symbol names
getIntersectionObserverMock, ioObserveMock, and ioDisconnectMock (in
link.test.tsx) and remove those lines, or alternatively, if they were intended
to be used, wire them into the tests (e.g., pass them to the Intersection
Observer mock setup) — otherwise simply remove the import and const
declarations.
e2e/preact-router/basic/tests/app.spec.ts-11-12 (1)

11-12: ⚠️ Potential issue | 🟡 Minor

Potential timing race between slow-title visible and suspense-fallback assertion.

The slow route resolves its Suspense content after a short fixed delay (200 ms per the PR description). Playwright's toBeVisible() polls the DOM until the condition is met or the timeout is reached — it does not succeed retroactively if the element already disappeared. If the time between line 11 (slow-title visible) and line 12 (suspense-fallback visible) exceeds that 200 ms delay on a slow/loaded CI runner, the fallback will have already been replaced, causing line 12 to time out and fail.

The safest fix is to increase the suspense delay in the test app (e.g., 2 000 ms instead of 200 ms), giving Playwright ample time to assert the fallback state before it resolves.

🛡️ Suggestion: increase delay in the slow route for test reliability

In e2e/preact-router/basic/src/main.tsx (or wherever the slow route is defined), increase the artificial delay:

-await new Promise((resolve) => setTimeout(resolve, 200))
+await new Promise((resolve) => setTimeout(resolve, 2000))
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@e2e/preact-router/basic/tests/app.spec.ts` around lines 11 - 12, The test can
race because the slow route's Suspense fallback may disappear before Playwright
asserts it; update the slow route's artificial delay (where the slow route is
defined, e.g., in e2e/preact-router/basic/src/main.tsx or the slow route
component) to a much larger value (e.g., increase from 200 ms to 2000 ms) so the
fallback with testIds 'suspense-fallback' and 'slow-title' remains present long
enough for both expects to reliably assert visibility.
packages/preact-router/tests/useParams.test.tsx-1-4 (1)

1-4: ⚠️ Potential issue | 🟡 Minor

Remove three unused imports: vi, fireEvent, and Link

All three are flagged by static analysis and are absent from the test body.

🔧 Proposed fix
-import { afterEach, expect, test, vi } from 'vitest'
-import { act, cleanup, fireEvent, render, screen } from '@testing-library/preact'
+import { afterEach, expect, test } from 'vitest'
+import { act, cleanup, render, screen } from '@testing-library/preact'
 import {
-  Link,
   Outlet,
   RouterProvider,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/preact-router/tests/useParams.test.tsx` around lines 1 - 4, The
import list includes three unused symbols — vi, fireEvent, and Link — which
should be removed to satisfy static analysis; update the top imports so only
used identifiers remain (e.g., keep afterEach, expect, test from 'vitest' and
act, cleanup, render, screen from '@testing-library/preact'), save the file, and
re-run the tests/linter to confirm the unused-import warnings are gone.
packages/preact-router/tests/router.test.tsx-1-1 (1)

1-1: ⚠️ Potential issue | 🟡 Minor

Fix misplaced Outlet import and remove unused vi

Three ESLint errors (flagged by static analysis) stem from the same root cause — Outlet was added at the bottom of the file rather than merged into the existing '../src' import block, and vi was imported without being used.

🔧 Proposed fix
-import { afterEach, describe, expect, test, vi } from 'vitest'
+import { afterEach, describe, expect, test } from 'vitest'
 import { cleanup, render, screen } from '@testing-library/preact'
 import {
+  Outlet,
   RouterProvider,
   createMemoryHistory,
   createRootRoute,
   createRoute,
   createRouter,
 } from '../src'
-// Need Outlet import
-import { Outlet } from '../src'

Also applies to: 109-110

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/preact-router/tests/router.test.tsx` at line 1, Move the misplaced
Outlet import into the existing import block that imports from '../src' (i.e.,
merge the separate Outlet import into that module import) and remove the unused
vi identifier from the test imports (the import line importing afterEach,
describe, expect, test, vi should drop vi). Update the import statements so
Outlet is imported alongside other exported symbols from '../src' and ensure no
unused vi import remains.
packages/preact-router/src/ssr/RouterClient.tsx-6-21 (1)

6-21: ⚠️ Potential issue | 🟡 Minor

TypeScript strict mode reports a type error: hydrationPromise may be undefined when passed to Await

The Await component requires its promise prop to be typed as Promise<T> (non-optional). After the if (!hydrationPromise) block, both branches assign a Promise, but TypeScript does not narrow module-level let variables (they can be mutated elsewhere in the module), so hydrationPromise retains the type Promise<void | Array<Array<void>>> | undefined. This is incompatible with the non-optional Promise<T> type expected by Await in strict mode.

The fix is either a non-null assertion or a local const:

🛡️ Proposed fixes
-      promise={hydrationPromise}
+      promise={hydrationPromise!}

Or:

+  const promise = hydrationPromise
   return (
     <Await
-      promise={hydrationPromise}
+      promise={promise}
       children={() => <RouterProvider router={props.router} />}
     />
   )

Secondary note — test-isolation: The module-level hydrationPromise is intentional to ensure a single hydration per page load, but any test rendering RouterClient directly will share this promise across test cases. If unit/integration tests exercise RouterClient, ensure the module-level variable is reset between tests (e.g., via vi.resetModules()).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/preact-router/src/ssr/RouterClient.tsx` around lines 6 - 21, The
module-level hydrationPromise variable can be undefined from TypeScript's
perspective when passed to Await; fix by creating a local non-optional promise
before rendering and pass that to Await — e.g. inside RouterClient after the
existing if-block declare a const promise = hydrationPromise! (or const promise
= hydrationPromise ?? Promise.resolve()) and pass promise to <Await> instead of
hydrationPromise; reference symbols: hydrationPromise, RouterClient, Await,
hydrate, RouterProvider.
packages/preact-router/tests/loaders.test.tsx-2-2 (1)

2-2: ⚠️ Potential issue | 🟡 Minor

Remove unused act import.

act is imported but never used — ESLint flags this as unused-imports/no-unused-imports.

🛠️ Proposed fix
-import { act, cleanup, fireEvent, render, screen } from '@testing-library/preact'
+import { cleanup, fireEvent, render, screen } from '@testing-library/preact'
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/preact-router/tests/loaders.test.tsx` at line 2, The import list in
the test currently includes an unused symbol `act`; update the top-level import
from '@testing-library/preact' to remove `act` so only used utilities
(`cleanup`, `fireEvent`, `render`, `screen`) are imported, ensuring no other
references to `act` remain in `loaders.test.tsx` (or any helper functions)
before committing the change.
e2e/preact-router/basic/playwright.config.ts-22-22 (1)

22-22: ⚠️ Potential issue | 🟡 Minor

Remove unnecessary escape characters around "test".

Inside a template literal, " does not need escaping. The \" sequences produce " at runtime (same result), but the ESLint no-useless-escape error is valid and the redundant escapes are misleading.

🛠️ Proposed fix
-    command: `VITE_NODE_ENV=\"test\" VITE_SERVER_PORT=${PORT} VITE_EXTERNAL_PORT=${EXTERNAL_PORT} pnpm build && pnpm preview --port ${PORT}`,
+    command: `VITE_NODE_ENV="test" VITE_SERVER_PORT=${PORT} VITE_EXTERNAL_PORT=${EXTERNAL_PORT} pnpm build && pnpm preview --port ${PORT}`,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@e2e/preact-router/basic/playwright.config.ts` at line 22, The
template-literal string assigned to the command variable contains unnecessary
escaped quotes around "test" (VITE_NODE_ENV=\"test\"); remove the backslashes so
the value is plain "test" inside the template literal (VITE_NODE_ENV="test") to
satisfy ESLint no-useless-escape—update the command assignment in the Playwright
config (the line defining command) accordingly.
packages/preact-router/tests/useBlocker.test.tsx-1-1 (1)

1-1: ⚠️ Potential issue | 🟡 Minor

Remove unused vi import.

vi is imported but never referenced — ESLint correctly flags this as unused-imports/no-unused-imports.

🛠️ Proposed fix
-import { afterEach, describe, expect, test, vi } from 'vitest'
+import { afterEach, describe, expect, test } from 'vitest'
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/preact-router/tests/useBlocker.test.tsx` at line 1, The import list
from 'vitest' includes an unused symbol `vi`; remove `vi` from the named imports
in the import statement so only used symbols (afterEach, describe, expect, test)
are imported, and run tests/lint to confirm no other references to `vi` remain
(if any mocking was intended, switch to the global `vi` instead of importing
it).
packages/preact-router/tests/ssr.test.tsx-49-76 (1)

49-76: ⚠️ Potential issue | 🟡 Minor

renderRouterToStream test is missing setRenderFinished assertion and has a weaker doctype check.

Two observations on this test:

  1. The renderRouterToStream implementation (in renderRouterToStream.tsx line 16) calls router.serverSsr!.setRenderFinished(), just like renderRouterToString. The first test asserts this (line 45) but this test does not. Add the missing assertion for parity.

  2. Line 72 asserts toContain('<html>') while test 1 (line 42) asserts toContain('<!DOCTYPE html>'). Both implementations prepend <!DOCTYPE html>, so the weaker check here could mask a regression.

Suggested fix
     expect(response.status).toBe(200)
-    expect(html).toContain('<html>')
+    expect(html).toContain('<!DOCTYPE html>')
     expect(html).toContain('Hello Stream SSR')
     expect(html).toContain('<script>window.__SSR__=1</script>')
+    expect(router.serverSsr.setRenderFinished).toHaveBeenCalledTimes(1)
     expect(router.serverSsr.cleanup).toHaveBeenCalledTimes(1)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/preact-router/tests/ssr.test.tsx` around lines 49 - 76, Add the
missing assertion that router.serverSsr.setRenderFinished was called and make
the doctype check strict: in the renderRouterToStream test update the
expectations to assert
expect(router.serverSsr.setRenderFinished).toHaveBeenCalledTimes(1) (matching
the renderRouterToString test) and replace the weaker
expect(html).toContain('<html>') with expect(html).toContain('<!DOCTYPE html>')
so the test verifies the explicit doctype and parity with the other test.
packages/preact-router/src/Matches.tsx-248-259 (1)

248-259: ⚠️ Potential issue | 🟡 Minor

useParentMatches / useChildMatches behave unexpectedly when contextMatchId is undefined.

If these hooks are called outside a matchContext.Provider (where contextMatchId is undefined), findIndex returns -1, causing slice(0, -1) to return all matches except the last instead of an empty array. A similar issue exists in useChildMatches at line 278, where slice(0) returns all matches.

This is unlikely to cause issues in practice since these hooks should only be called within a rendered route tree, but a guard could make it more predictable:

🛡️ Optional defensive guard
 export function useParentMatches<...>(...): ... {
   const contextMatchId = useContext(matchContext)

   return useMatches({
     select: (matches: Array<MakeRouteMatchUnion<TRouter>>) => {
+      const index = matches.findIndex((d) => d.id === contextMatchId)
       matches = matches.slice(
         0,
-        matches.findIndex((d) => d.id === contextMatchId),
+        index === -1 ? 0 : index,
       )
       return opts?.select ? opts.select(matches) : matches
     },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/preact-router/src/Matches.tsx` around lines 248 - 259, When
contextMatchId is undefined the select callback's findIndex returns -1 causing
slice to return the wrong subset; update the select in useParentMatches and the
analogous select in useChildMatches to first check for contextMatchId (or that
findIndex returned -1) and return an empty array (or appropriate empty slice)
instead of slicing with -1 — locate the select callbacks in
useParentMatches/useChildMatches, compute const idx = matches.findIndex(d =>
d.id === contextMatchId), and if idx === -1 return [] (or the empty result
expected by that hook) otherwise return opts?.select ?
opts.select(matches.slice(0, idx)) : matches.slice(0, idx).
🧹 Nitpick comments (23)
packages/preact-router/src/Transitioner.tsx (1)

32-37: isTransitioning is effectively always false due to synchronous batching in Preact.

Unlike React's Transitioner (which wraps the call in React.startTransition() to prevent batching), Preact's synchronous setIsTransitioning(true) and setIsTransitioning(false) calls on lines 34–36 are batched together in a single render cycle. This means isAnyPending (line 26) effectively always reduces to isLoading || hasPendingMatches, making the isTransitioning state unused and potentially confusing for maintainers.

The comment on line 32 acknowledges this limitation. Consider either removing isTransitioning entirely or adding a brief comment at line 15 noting it's a no-op placeholder for parity with the React version.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/preact-router/src/Transitioner.tsx` around lines 32 - 37,
isTransitioning is effectively a no-op in this Preact implementation because
router.startTransition sets setIsTransitioning(true) and immediately
setIsTransitioning(false), so the state never persists; update the code to
either remove isTransitioning and all references (including the isTransitioning
state and the router.startTransition implementation) or clearly mark it as a
deliberate no-op for parity with React by adding a concise comment near the top
where isTransitioning is declared and leaving router.startTransition as a
placeholder; ensure you update any derived boolean (isAnyPending) to reflect
removal (use isLoading || hasPendingMatches) if you remove isTransitioning, and
remove any unused imports or variables tied to isTransitioning.
examples/preact/basic/tsconfig.json (1)

5-6: Mixed indentation: lines 5–6 use tabs while the rest of the file uses spaces.

🧹 Suggested fix
-		"jsx": "react-jsx",
-		"jsxImportSource": "preact",
+    "jsx": "react-jsx",
+    "jsxImportSource": "preact",
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/preact/basic/tsconfig.json` around lines 5 - 6, Lines containing the
"jsx" and "jsxImportSource" properties use tabs while the rest of the
tsconfig.json uses spaces; replace the tabs on the "jsx" and "jsxImportSource"
lines with the same space-based indentation used elsewhere so the file
indentation is consistent and no mixed tabs/spaces remain.
packages/preact-router/tests/setupTests.tsx (1)

5-5: Consider adding a type cast for strict-mode compatibility.

vi.fn() returns Mock<[], void>, which is not assignable to the overloaded Window['scrollTo'] signature under strict TypeScript. This may surface as a type error depending on the package's tsconfig coverage for test files.

🛡️ Suggested fix
-window.scrollTo = vi.fn()
+window.scrollTo = vi.fn() as unknown as typeof window.scrollTo
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/preact-router/tests/setupTests.tsx` at line 5, Assign a properly
typed mock to window.scrollTo to satisfy strict TypeScript: replace the plain
vi.fn() assignment by casting the mock (vi.fn()) to the Window['scrollTo'] type
(or via unknown then to Window['scrollTo']) so the mocked function signature
matches window.scrollTo; update the code where window.scrollTo is set and
reference vi.fn() and window.scrollTo in the change.
pnpm-workspace.yaml (1)

15-17: e2e/preact-router/* is ordered after e2e/react-router/*, breaking alphabetical consistency with the rest of the e2e/ entries.

🧹 Suggested fix
+  - 'e2e/preact-router/*'
   - 'e2e/react-router/*'
-  - 'e2e/preact-router/*'
   - 'e2e/solid-router/*'
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pnpm-workspace.yaml` around lines 15 - 17, The e2e entries in
pnpm-workspace.yaml are out of alphabetical order: 'e2e/preact-router/*' should
come before 'e2e/react-router/*'. Edit the list so the three entries are
alphabetically ordered (place 'e2e/preact-router/*' above 'e2e/react-router/*',
keeping 'e2e/solid-router/*' after them) to restore consistency.
examples/preact/basic/package.json (1)

11-18: @tailwindcss/vite and tailwindcss belong in devDependencies.

Both are build-time tools — they produce no runtime output. Listing them under dependencies would unnecessarily inflate node_modules for consumers doing a production install. preact-render-to-string (line 15) is an SSR library; if this example is client-side only, it too should be in devDependencies or removed.

🛠️ Suggested fix
  "dependencies": {
-   "@tailwindcss/vite": "^4.1.18",
    "@tanstack/preact-router": "^1.160.0",
    "preact": "^10.25.0",
-   "preact-render-to-string": "^6.6.5",
    "redaxios": "^0.5.1",
-   "tailwindcss": "^4.1.18"
  },
  "devDependencies": {
+   "@tailwindcss/vite": "^4.1.18",
    "@preact/preset-vite": "^2.10.3",
+   "preact-render-to-string": "^6.6.5",
+   "tailwindcss": "^4.1.18",
    "typescript": "^5.7.2",
    "vite": "^7.3.1"
  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/preact/basic/package.json` around lines 11 - 18, Move build-only
packages out of the dependencies block: remove "@tailwindcss/vite" and
"tailwindcss" from the "dependencies" object and add them to "devDependencies"
instead; also evaluate "preact-render-to-string" (SSR-only) — either move it to
"devDependencies" or remove it if this example is strictly client-side; update
the package.json so runtime "dependencies" only contain actual runtime libs like
"preact", "@tanstack/preact-router", and "redaxios".
packages/preact-router/tests/navigate.test.tsx (1)

84-87: any annotation on the param function can be narrowed.

The (p: any) on line 86 opts out of type checking on the existing params object. Since strict TypeScript is required for this project, consider using the router's inferred Params type or typeof router utilities instead.

♻️ Suggested improvement
-      params: (p: any) => ({ ...p, slug: 'tkdodo' }),
+      params: (p) => ({ ...p, slug: 'tkdodo' }),

Removing the explicit annotation lets TypeScript infer the param type from the route definition, which is safer and keeps the test in strict mode.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/preact-router/tests/navigate.test.tsx` around lines 84 - 87, The
params callback passed to router.navigate currently uses an explicit any
annotation `(p: any)` which disables strict typing; remove the explicit
annotation so TypeScript infers the correct params type from the router (or
replace with the router's inferred Params utility if you prefer explicit
typing), e.g., update the params callback in the router.navigate call so the
parameter is untyped (let inference handle it) or typed with the router's Params
type instead of any to restore strict type checking.
examples/preact/basic/src/styles.css (1)

3-18: Minor redundancy: border color set in both @layer base and the bare * rule.

The @layer base block at lines 4–10 sets border-color: var(--color-gray-200, currentcolor) on * and all pseudo-elements. The bare * { @apply border-gray-200 dark:border-gray-800; } at line 16 re-applies the same light-mode color to * (outside a layer, so it wins the cascade). The @layer base block is still needed to cover pseudo-elements, but for * itself the two declarations are redundant in light mode.

♻️ Suggested consolidation
 `@layer` base {
   *,
   ::after,
   ::before,
   ::backdrop,
   ::file-selector-button {
     border-color: var(--color-gray-200, currentcolor);
   }
+  `@media` (prefers-color-scheme: dark) {
+    * {
+      border-color: var(--color-gray-800, currentcolor);
+    }
+  }
 }

 html {
   color-scheme: light dark;
 }
-* {
-  `@apply` border-gray-200 dark:border-gray-800;
-}
 body {
   `@apply` bg-gray-50 text-gray-950 dark:bg-gray-900 dark:text-gray-200;
 }

Alternatively, keep the @apply approach and drop the @layer base if pseudo-element coverage isn't needed for this example.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/preact/basic/src/styles.css` around lines 3 - 18, The rules set
border color twice for the universal selector: once inside the `@layer` base block
(selectors: *, ::after, ::before, ::backdrop, ::file-selector-button) and again
in the bare universal rule (* { `@apply` border-gray-200 dark:border-gray-800; }),
causing redundancy; remove the duplicate for the universal selector by keeping
the `@layer` base rule only for pseudo-elements and adjusting it to exclude the
bare * (or conversely delete the bare * rule and explicitly add pseudo-element
selectors inside `@layer` base), ensuring pseudo-elements still receive
border-color while the universal selector is defined in one place (refer to the
selectors and the bare '*' rule to locate where to change).
packages/preact-router/tests/utils.ts (1)

31-38: thresholdthresholds type coercion is masked by as any.

IntersectionObserverInit.threshold is number | number[], but IntersectionObserver.thresholds is ReadonlyArray<number>. When threshold is a single number, this assigns a number to an array-typed field. Since this is test-only code the impact is minimal, but a proper normalization would be safer.

Suggested fix
-    this.thresholds = options?.threshold ?? ([0] as any)
+    const t = options?.threshold ?? [0]
+    this.thresholds = Array.isArray(t) ? t : [t]
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/preact-router/tests/utils.ts` around lines 31 - 38, The constructor
currently coerces IntersectionObserverInit.threshold into this.thresholds using
"as any", which can assign a single number to thresholds (should be
ReadonlyArray<number>); update the normalization in the constructor so if
options?.threshold is a number convert it to an array [number], if it's already
an array use it as-is, and default to [0] when undefined; modify the assignments
around this.root, this.rootMargin, and this.thresholds in the constructor to
perform this safe normalization for IntersectionObserverInit.threshold.
packages/preact-router/src/routerContext.tsx (1)

21-21: Remove unnecessary as any cast.

routerContext is Context<AnyRouter> and window.__TSR_ROUTER_CONTEXT__ is declared as ReturnType<typeof createContext<AnyRouter>> (also Context<AnyRouter>). These types are directly assignable without the cast.

Proposed fix
-  window.__TSR_ROUTER_CONTEXT__ = routerContext as any
+  window.__TSR_ROUTER_CONTEXT__ = routerContext
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/preact-router/src/routerContext.tsx` at line 21, Remove the
unnecessary type assertion when assigning the router context to the global:
replace the `window.__TSR_ROUTER_CONTEXT__ = routerContext as any` assignment
with a direct assignment since `routerContext` is already `Context<AnyRouter>`
and matches `ReturnType<typeof createContext<AnyRouter>>`; update the single
assignment site referencing `routerContext` and `window.__TSR_ROUTER_CONTEXT__`
to drop the `as any` cast.
packages/preact-router/package.json (2)

66-68: Node engine minimum of 12 is extremely dated.

Node 12 has been EOL since April 2022. If this is a project-wide convention, it's fine to keep for consistency, but worth noting that modern Node features (e.g., ESM support, structuredClone) may not be available at this floor.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/preact-router/package.json` around lines 66 - 68, Update the
package.json engines policy to a modern Node LTS baseline by changing the
"engines" -> "node" value from ">=12" to a current supported minimum (for
example ">=16" or ">=18"); edit the "engines" object in package.json (the "node"
property) to the chosen newer version, run CI/test to ensure compatibility, and
if this is a repo-wide convention coordinate with other packages to keep the
same minimum across the project.

69-77: Consider making SSR-only dependencies optional or peer dependencies.

preact-render-to-string and isbot are only needed by the ./ssr/server entrypoint, but they're listed as unconditional runtime dependencies. Consumers who only use client-side routing will still install these. Consider moving them to peerDependencies with peerDependenciesMeta marking them as optional, or documenting this trade-off.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/preact-router/package.json` around lines 69 - 77, The package lists
SSR-only libs ("preact-render-to-string" and "isbot") under dependencies causing
clients to install them unnecessarily; update package.json to move these two
packages out of "dependencies" into "peerDependencies" and add a
"peerDependenciesMeta" entry marking them as optional (e.g.,
"preact-render-to-string": { "optional": true }, "isbot": { "optional": true })
so the ./ssr/server entrypoint can require them only when present;
alternatively, if you prefer consumers not to provide them, move them to
"optionalDependencies" or document the SSR-only requirement in the README for
the ./ssr/server entrypoint.
packages/preact-router/src/useNavigate.tsx (1)

54-59: Props reference check is always true — navigation fires on every re-render.

previousPropsRef.current !== props is a reference-identity comparison, but Preact (like React) creates a new props object on every render. Combined with props in the effect's dependency array, this means navigate(props) runs on every re-render of the parent component, not just when the navigation target changes.

This mirrors the React router's Navigate implementation and works in practice because the component is typically used for one-shot redirects that unmount quickly. If this is intentional, a brief comment explaining the rationale would help future readers. Otherwise, a shallow comparison of the navigation-relevant fields would be more robust.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/preact-router/src/useNavigate.tsx` around lines 54 - 59, The effect
in useLayoutEffect currently compares previousPropsRef.current !== props using
reference identity, causing navigate(props) to run every render; change this to
a shallow equality check of the navigation-relevant fields (e.g., compare
previousPropsRef.current.to, .replace, .state or whatever props are used by
navigate) before calling navigate, and update previousPropsRef.current only when
those relevant fields differ; alternatively, if one-shot redirects are intended,
add a concise comment above the useLayoutEffect explaining that reference
inequality is expected and intentional to avoid future confusion.
packages/preact-router/src/CatchBoundary.tsx (1)

80-119: ErrorComponent silently swallows non-Error thrown values.

When error is not an Error instance (e.g., a thrown string or object without .message), error.message is undefined and the <pre> renders empty. Consider a fallback like String(error) to surface something useful.

Proposed fallback for non-Error values
-            {error.message ? <code>{error.message}</code> : null}
+            <code>{error.message ?? String(error)}</code>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/preact-router/src/CatchBoundary.tsx` around lines 80 - 119,
ErrorComponent currently only renders error.message and therefore shows nothing
for non-Error throws; update the rendering logic in ErrorComponent (the error
prop handling) to fall back to a stringified representation when error.message
is falsy: if error is an object use JSON.stringify(error, null, 2) (with
try/catch to avoid circular errors) otherwise use String(error) or 'Unknown
error' for null/undefined, and render that inside the existing <pre>/<code> area
so non-Error values are visible.
packages/preact-router/src/useBlocker.tsx (2)

263-285: _resolvePromptBlockerArgs largely duplicates _resolveBlockerOpts.

The legacy-options conversion logic (lines 270-284) is nearly identical to lines 115-129 of _resolveBlockerOpts. Consider extracting the shared legacy-to-opts conversion into a common helper to reduce duplication.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/preact-router/src/useBlocker.tsx` around lines 263 - 285, The logic
in _resolvePromptBlockerArgs that converts legacy props (condition/blockerFn)
into UseBlockerOpts duplicates _resolveBlockerOpts; extract that shared
conversion into a single helper (e.g., normalizeLegacyPromptProps or
legacyToUseBlockerOpts) which takes a PromptProps | LegacyPromptProps and
returns { shouldBlockFn, enableBeforeUnload, withResolver } by computing
shouldBlock = Boolean(props.condition ?? true), wrapping blockerFn into
_customBlockerFn and setting enableBeforeUnload and withResolver accordingly;
then replace the duplicated block in both _resolvePromptBlockerArgs and
_resolveBlockerOpts to call the new helper and return its result.

224-245: Misleading variable name canNavigateAsync.

canNavigateAsync holds true when navigation should be blocked (from reset) and false when it should proceed. The name suggests the opposite. Consider renaming to shouldBlockResult or shouldStayOnPage for clarity.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/preact-router/src/useBlocker.tsx` around lines 224 - 245, The
variable canNavigateAsync is misnamed because it is true when navigation should
be blocked (reset) and false when it should proceed; rename it to a clear name
like shouldStayOnPage or shouldBlock to reflect that truthy means "stay" and
update the return to match the new name; update references in this function (the
Promise resolver logic using proceed/reset and the setResolver call) and any
callers expecting the old boolean semantics so behavior remains unchanged after
the rename.
packages/preact-router/src/router.ts (1)

73-79: window fallback branch is unreachable.

globalThis is defined in all modern JS environments (browsers, Node.js, Deno, workers). The else if (typeof window !== 'undefined') branch on line 76 is effectively dead code. This mirrors the React router's pattern, so it may be intentional for legacy compatibility, but worth noting.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/preact-router/src/router.ts` around lines 73 - 79, The fallback
branch checking window is unreachable because globalThis exists in modern
environments; remove the redundant "else if (typeof window !== 'undefined')"
block and keep only the globalThis assignments for createFileRoute and
createLazyFileRoute (or alternatively convert the else-if into a separate
independent if that also assigns to window if you need legacy window exposure),
referencing the globalThis and window symbols and the
createFileRoute/createLazyFileRoute assignments to locate the code to change.
packages/preact-router/src/HeadContent.tsx (1)

16-16: JSON.stringify(tag) as a key is called on every render for every tag.

The entire tag object (including attrs and children) is serialized to compute the React/Preact key on every render pass. For routes with inline scripts or large style blobs, this serialization is non-trivial. A more targeted stable key (e.g., combining tag.tag with a discriminating attribute like href, src, name, or a hash) would avoid repeated full-object serialization.

♻️ Example of a leaner key strategy
-<Asset {...tag} key={`tsr-meta-${JSON.stringify(tag)}`} nonce={nonce} />
+<Asset
+  {...tag}
+  key={`tsr-meta-${tag.tag}-${
+    tag.attrs
+      ? (tag.attrs.href ??
+         tag.attrs.src ??
+         tag.attrs.name ??
+         tag.attrs.property ??
+         tag.attrs.rel ??
+         JSON.stringify(tag.attrs))
+      : tag.children
+  }`}
+  nonce={nonce}
+/>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/preact-router/src/HeadContent.tsx` at line 16, The current key uses
JSON.stringify(tag) which serializes the whole tag object on every render;
update the key generation in HeadContent (where Asset is rendered with props
{...tag} and nonce) to use a stable, lightweight discriminator such as
`${tag.tag}-${tag.attrs?.href||tag.attrs?.src||tag.attrs?.name||tag.attrs?.id||index}`
or, if you need collision resistance, compute a small hash only over the
relevant attrs (not children) and use that instead; ensure you reference Asset
and the local variable tag (and fallback to the loop index) so the key is
deterministic, cheap to compute, and avoids serializing large children blobs.
packages/preact-router/tests/loaders.test.tsx (1)

129-133: expect inside loader function obscures assertion failure messages.

When expect(parentMatch.loaderData).toBe('nested') fails inside the loader, the thrown error is caught by the router's loader error handling rather than propagating directly to Vitest. The test then fails with a findByText timeout instead of the original assertion message, making failures hard to diagnose. Consider asserting after the route resolves instead:

♻️ Suggested refactor
     const fooRoute = createRoute({
       getParentRoute: () => nestedRoute,
       path: '/foo',
       loader: async ({ parentMatchPromise }) => {
         nestedLoaderMock(parentMatchPromise)
-        const parentMatch = await parentMatchPromise
-        expect(parentMatch.loaderData).toBe('nested')
+        return parentMatchPromise
       },
       component: () => <div>Nested Foo page</div>,
     })
     // ...
     const fooElement = await screen.findByText('Nested Foo page')
     expect(fooElement).toBeInTheDocument()

+    const resolvedParent = await nestedLoaderMock.mock.calls[0]?.[0]
+    expect(resolvedParent).toBeDefined()
+    expect((await resolvedParent).loaderData).toBe('nested')
     expect(nestedLoaderMock).toHaveBeenCalled()
     expect(nestedLoaderMock.mock.calls[0]?.[0]).toBeInstanceOf(Promise)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/preact-router/tests/loaders.test.tsx` around lines 129 - 133, The
assertion inside the async loader is swallowed by the router's loader error
handling; move the check out of the loader and into the test after the route has
resolved: have the loader call nestedLoaderMock(parentMatchPromise) and await
parentMatchPromise inside the test (or wait for the rendered route via
findByText), then assert parentMatch.loaderData === 'nested' in the test body
rather than inside the loader function so Vitest receives the actual assertion
failure; reference the loader block using the loader: async ({
parentMatchPromise }) => { ... } and the nestedLoaderMock(parentMatchPromise)
call to locate where to remove the inline expect and place the assertion after
resolution.
packages/preact-router/src/ssr/renderRouterToStream.tsx (1)

5-16: renderRouterToStream is named for streaming but returns a fully-buffered Response.

The implementation uses renderToStringAsync (which resolves all promises into a single string) and wraps it in a non-streaming new Response(...). This is behaviorally identical to renderRouterToString and does not produce an incremental/chunked response. Consumers expecting a true streaming response would be misled.

Given the PR notes acknowledge this ("not battle-tested"), consider renaming to renderRouterToStringAsync or adding a clear // NOTE: not a true streaming response JSDoc comment until actual streaming is implemented.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/preact-router/src/ssr/renderRouterToStream.tsx` around lines 5 - 16,
The function renderRouterToStream is misnamed because it fully buffers output
using renderToStringAsync and returns a non-streaming Response; rename the
exported function to renderRouterToStringAsync (update the function declaration
and export symbol renderRouterToStream -> renderRouterToStringAsync) and update
all internal references/imports/tests/docs that call renderRouterToStream;
alternatively, if you prefer to keep the name, add a clear JSDoc above
renderRouterToStream stating "// NOTE: not a true streaming response — returns a
fully-buffered Response via renderToStringAsync" and ensure the export and any
consumers are aware of the non-streaming behavior (adjust tests/docs
accordingly).
packages/preact-router/src/headContentUtils.tsx (1)

60-66: Nonce is unnecessarily added to <meta> tag attributes.

nonce is a valid attribute for link, style, and script elements per the CSP spec, but not meaningful on <meta> tags. This won't break anything but emits unnecessary markup.

🔧 Remove nonce from meta attrs
           resultMeta.push({
             tag: 'meta',
             attrs: {
               ...m,
-              nonce,
             },
           })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/preact-router/src/headContentUtils.tsx` around lines 60 - 66, In
headContentUtils.tsx the code that builds resultMeta pushes meta entries with
attrs: { ...m, nonce }, which adds a nonce attribute to <meta> tags
unnecessarily; update the construction of resultMeta (the object pushed in the
resultMeta array where tag === 'meta' and attrs are built) to omit nonce for
meta tags (remove adding the nonce property or conditionally include nonce only
for tag values 'script', 'style', or 'link') so meta elements no longer receive
the nonce attribute.
packages/preact-router/tests/ssr.test.tsx (1)

1-77: renderRouterToStream and renderRouterToString have identical implementations — neither actually implements streaming.

Both functions use renderToStringAsync to produce a complete HTML string and return a fully-rendered Response. The request parameter in renderRouterToStream is unused. Since the implementations are functionally identical, tests won't catch if they diverge or if streaming support is later added to one but not the other.

Either document that both currently delegate to string-based rendering, or consolidate them until true streaming support is added.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/preact-router/tests/ssr.test.tsx` around lines 1 - 77,
renderRouterToStream duplicates renderRouterToString by producing a full string
instead of streaming; fix by consolidating implementations so only one real
renderer exists: have renderRouterToStream delegate to renderRouterToString (or
vice‑versa) and forward the request/params (or remove the unused request param)
so there’s a single source of truth, and add a TODO comment noting true
streaming is not yet implemented in the chosen function (referencing
renderRouterToStream and renderRouterToString).
packages/preact-router/src/Scripts.tsx (1)

48-55: Remove suppressHydrationWarning—it's a React-only prop that gets filtered out in Preact anyway.

suppressHydrationWarning is a React-specific JSX prop that has no meaning in Preact. While the Preact Asset.tsx component (lines 82, 130) explicitly filters it out before applying attributes to DOM elements, including it here is an unnecessary artifact from the React implementation and creates confusion about intent.

Suggested fix
      .map(({ children, ...script }) => ({
        tag: 'script',
        attrs: {
          ...script,
-         suppressHydrationWarning: true,
          nonce,
        },
        children,
      })),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/preact-router/src/Scripts.tsx` around lines 48 - 55, In Scripts.tsx
remove the React-only prop suppressHydrationWarning from the attrs object
created in the map that builds script elements (the block mapping
children,...script => ({ tag: 'script', attrs: { ...script,
suppressHydrationWarning: true, nonce }, children })). Instead only spread the
script props and nonce into attrs; rely on Asset.tsx's existing filtering of
suppressHydrationWarning when normalizing DOM attributes so Preact consumers
don't see React-specific props and to avoid confusion.
packages/preact-router/src/route.tsx (1)

267-316: Consider extracting shared hook wiring to reduce duplication across RouteApi, Route, and RootRoute.

RouteApi (lines 108-168), Route (lines 267-316), and RootRoute (lines 525-574) each implement nearly identical sets of 8 hook-method bindings (useMatch, useRouteContext, useSearch, useParams, useLoaderDeps, useLoaderData, useNavigate, Link). A shared helper or mixin could centralize this, reducing maintenance surface and risk of subtle divergence (like the strict: false difference noted above).

This is understandable for an initial port — just flagging for future consolidation.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/preact-router/src/route.tsx` around lines 267 - 316, The RouteApi,
Route, and RootRoute classes duplicate the same 8 hook bindings (useMatch,
useRouteContext, useSearch, useParams, useLoaderDeps, useLoaderData,
useNavigate, Link); extract that wiring into a shared helper or mixin (e.g.,
createRouteHooks) which accepts the route identity (id, fullPath) and any
Link/component references and returns the bound functions, then replace the
inline implementations in RouteApi, Route, and RootRoute to delegate to that
helper so all classes consume a single canonical implementation and avoid
divergence.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
packages/preact-router/src/link.tsx (1)

478-566: Event handlers recreated on every render; consider useCallback.

handleClick, handleFocus, handleEnter, handleLeave, and handleTouchStart are plain inline functions. Each render produces new references that propagate through composeHandlers as new onClick/onFocus/etc. values, defeating memoization for any downstream component that checks prop reference equality.

Wrapping each handler in useCallback with an appropriate dependency array would stabilise the references.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/preact-router/src/link.tsx` around lines 478 - 566, The event
handlers (handleClick, handleFocus, handleEnter, handleLeave, and
handleTouchStart) should be wrapped with React's useCallback to stabilize
references; for each handler create a useCallback that lists only the values it
reads as dependencies (e.g., handleClick depends on disabled, isCtrlEvent,
setIsTransitioning, router, _options, replace, resetScroll, hashScrollIntoView,
startTransition, viewTransition, ignoreBlocker; handleFocus depends on disabled,
preload, doPreload; handleTouchStart should be a stable alias to the memoized
handleFocus; handleEnter depends on disabled, preload, preloadDelay, doPreload,
timeoutMap; handleLeave depends on disabled, preload, preloadDelay, timeoutMap).
Ensure timeoutMap remains a stable reference (e.g., ref or module-level) or
include it in dependencies so the callbacks remain correct.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/preact-router/src/link.tsx`:
- Around line 493-510: isTransitioning can get stuck and subscriptions leak:
ensure navigate failures and unmounts clear the transition and unsubscribe.
Update the click/navigation handler (where setIsTransitioning(true) is set,
router.subscribe('onResolved', ...) creates unsub, and router.navigate(...) is
called) to: wrap router.navigate(...) in try/catch/await (or handle its returned
promise) so failures/cancellations call setIsTransitioning(false); store unsub
and a flag (e.g., unsubRef/unsubbedRef) in a ref so a useEffect cleanup can call
unsub() and mark unsubbed to prevent setIsTransitioning on unmounted components;
inside the onResolved callback check the unsubbed flag before calling
setIsTransitioning(false) and always clear/unsubscribe after resolution or
error.

---

Duplicate comments:
In `@packages/preact-router/src/link.tsx`:
- Around line 767-772: The function-children callback signature
(LinkPropsChildren) expects both isActive and isTransitioning but only isActive
is passed; update the children invocation so it also passes a boolean
isTransitioning derived from cleanLinkProps (e.g., compute isTransitioning =
Boolean((cleanLinkProps)['data-transitioning'] ||
(cleanLinkProps)['data-is-transitioning'] ||
(cleanLinkProps)['data-transition'])) and pass it along with isActive when
calling rest.children so consumers receive both values.

---

Nitpick comments:
In `@packages/preact-router/src/link.tsx`:
- Around line 478-566: The event handlers (handleClick, handleFocus,
handleEnter, handleLeave, and handleTouchStart) should be wrapped with React's
useCallback to stabilize references; for each handler create a useCallback that
lists only the values it reads as dependencies (e.g., handleClick depends on
disabled, isCtrlEvent, setIsTransitioning, router, _options, replace,
resetScroll, hashScrollIntoView, startTransition, viewTransition, ignoreBlocker;
handleFocus depends on disabled, preload, doPreload; handleTouchStart should be
a stable alias to the memoized handleFocus; handleEnter depends on disabled,
preload, preloadDelay, doPreload, timeoutMap; handleLeave depends on disabled,
preload, preloadDelay, timeoutMap). Ensure timeoutMap remains a stable reference
(e.g., ref or module-level) or include it in dependencies so the callbacks
remain correct.

await page.goto('/')
})

test('shows suspense fallback and then resolved content', async ({ page }) => {
Copy link

@Hardanish-Singh Hardanish-Singh Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit]: test('shows suspense fallback and then resolves content', async ({ page }) => { .... }

children: VNode
}) => {
try {
let html = await renderToStringAsync(children)
Copy link
Author

@JoviDeCroock JoviDeCroock Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is intentionally using our renderToStringAsync - currently preact doesn't have the best story for full document renders. Some examples of problems can be found in preactjs/preact#5029 and preactjs/preact#5028

That being said, RTSasync does a full render, including suspense boundaries

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants