Skip to content
Open
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
45 changes: 45 additions & 0 deletions docs/router.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
### `notFound` — Custom 404 fallback

**Type:** `React.ComponentType<{ path: string }>` | optional

When navigation targets a path with no registered route, the router renders
the `notFound` component instead of a blank screen. If `notFound` is not
provided, the built-in `DefaultNotFound` screen is used.

**Default behavior** (no `notFound` prop):

```tsx
// Navigating to an unregistered path renders:
// ╭─────────────────────────────╮
// │ Route Not Found │
// │ │
// │ No screen is registered for: /settings/unknown
// │ │
// │ Press Backspace to go back · Press Q to quit
// ╰─────────────────────────────╯
```

**Custom fallback:**

```tsx
<Router
config={{
routes: [
{ path: '/', component: Home },
{ path: '/about', component: About },
],
notFound: ({ path }) => (
<Box border="round">
<Text color="red">No screen at {path}</Text>
<Text dim>Press Q to quit</Text>
</Box>
),
}}
/>
```

**Importing `DefaultNotFound` directly** (if you want to compose it):

```tsx
import { DefaultNotFound } from '@termuijs/router';
```
59 changes: 59 additions & 0 deletions packages/data/src/DefaultNotFound.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// packages/router/src/DefaultNotFound.tsx
// New file for Issue #2120 — default fallback rendered when no route matches

import { Box, Text } from '@termuijs/widgets';
import { useKeymap } from '@termuijs/jsx';
import { useRouter } from './useRouter';

export interface NotFoundProps {
/** The path that was requested but had no registered route */
path: string;
}
Comment on lines +1 to +11

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify existence and export location of useRouter, and confirm which DefaultNotFound router.ts imports
fd -a 'useRouter.ts' packages
rg -n 'export function useRouter|export const useRouter' packages/router/src
rg -n "import.*DefaultNotFound" packages/router/src/router.ts

Repository: Karanjot786/TermUI

Length of output: 291


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== files ==\n'
git ls-files 'packages/**/DefaultNotFound.tsx' 'packages/router/src/hooks.ts' 'packages/router/src/router.ts' 'packages/router/src/DefaultNotFound.tsx' 'packages/data/src/DefaultNotFound.tsx'

printf '\n== line counts ==\n'
wc -l packages/data/src/DefaultNotFound.tsx packages/router/src/DefaultNotFound.tsx packages/router/src/hooks.ts packages/router/src/router.ts

printf '\n== packages/data/src/DefaultNotFound.tsx ==\n'
cat -n packages/data/src/DefaultNotFound.tsx

printf '\n== packages/router/src/DefaultNotFound.tsx ==\n'
cat -n packages/router/src/DefaultNotFound.tsx

printf '\n== packages/router/src/hooks.ts ==\n'
cat -n packages/router/src/hooks.ts

printf '\n== packages/router/src/router.ts excerpt ==\n'
sed -n '1,80p' packages/router/src/router.ts

Repository: Karanjot786/TermUI

Length of output: 9068


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== references to DefaultNotFound ==\n'
rg -n "DefaultNotFound" packages -g '!**/dist/**'

printf '\n== useRouter refs in packages/data ==\n'
rg -n "useRouter" packages/data packages/router/src -g '!**/dist/**'

printf '\n== package entrypoints for data and router ==\n'
git ls-files 'packages/data/src/*' 'packages/router/src/*' | sort

Repository: Karanjot786/TermUI

Length of output: 4401


Move DefaultNotFound into packages/router
packages/data/src/DefaultNotFound.tsx imports ./useRouter, but that module doesn’t exist in packages/data; useRouter lives in packages/router/src/hooks.ts. This component belongs in packages/router/src/DefaultNotFound.tsx, replacing the stale placeholder there.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/data/src/DefaultNotFound.tsx` around lines 1 - 11, Move the
DefaultNotFound component out of packages/data and into packages/router so its
relative imports resolve correctly. Update the component module to live
alongside useRouter in packages/router/src, and make sure DefaultNotFound
imports useRouter from the router package’s hooks module instead of a
non-existent local ./useRouter path. Keep the existing
DefaultNotFoundProps/NotFoundProps and component behavior intact while replacing
the stale placeholder with the real router fallback.


/**
* DefaultNotFound
*
* Rendered automatically by <Router> when navigation targets an unregistered path.
* Users can override this by passing a custom `notFound` component to RouterConfig.
*
* Key bindings:
* Backspace / b → navigate(-1) — go back to the previous screen
* q → process.exit(0) — quit the terminal app
*/
export function DefaultNotFound({ path }: NotFoundProps) {
const { navigate } = useRouter();

useKeymap({
// Go back to the previous route
'backspace': () => navigate(-1),
'b': () => navigate(-1),
// Quit the app (standard terminal convention)
'q': () => process.exit(0),
'ctrl+c': () => process.exit(0),
});

return (
<Box
border="round"
padding={1}
flexDirection="column"
gap={1}
>
{/* Title */}
<Text bold color="red">
Route Not Found
</Text>

{/* The unmatched path */}
<Text>
{'No screen is registered for: '}
<Text bold color="yellow">{path}</Text>
</Text>

{/* Help text */}
<Text dim>
Press Backspace to go back · Press Q to quit
</Text>
</Box>
);
}
3 changes: 2 additions & 1 deletion packages/router/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@
},
"scripts": {
"build": "tsup",
"dev": "tsup --watch"
"dev": "tsup --watch",
"test": "vitest run"
},
"dependencies": {
"@termuijs/core": "workspace:*",
Expand Down
33 changes: 33 additions & 0 deletions packages/router/src/DefaultNotFound.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// packages/router/src/DefaultNotFound.tsx
import { type VNode } from '@termuijs/jsx';

export function DefaultNotFound({ path }: { path: string }): VNode {
return {
type: 'box',
props: {
border: 'single',
borderColor: 'yellow',
padding: 2,
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
},
children: [
{
type: 'text',
props: { color: 'yellow', bold: true, size: 2 },
children: ['404 – Page Not Found']

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Hardcoded non-ASCII character without caps.unicode fallback.

The en dash in '404 – Page Not Found' is emitted unconditionally. As per coding guidelines, packages/**/*.{ts,tsx}: "Use caps.unicode for non-ASCII characters with an ASCII fallback. Example: caps.unicode ? '█' : '#'."

🌐 Suggested fix
-                children: ['404 – Page Not Found']
+                children: [caps.unicode ? '404 – Page Not Found' : '404 - Page Not Found']
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
children: ['404 – Page Not Found']
children: [caps.unicode ? '404 – Page Not Found' : '404 - Page Not Found']
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/router/src/DefaultNotFound.tsx` at line 19, The DefaultNotFound
component is emitting a hardcoded en dash in its 404 text without an ASCII
fallback. Update the string in DefaultNotFound to use caps.unicode to choose
between the non-ASCII version and an ASCII-safe fallback, following the existing
caps.unicode pattern used elsewhere in the codebase.

Source: Coding guidelines

},
{
type: 'text',
props: { color: 'gray', dim: true },
children: [`Path: ${path}`]
},
{
type: 'text',
props: { color: 'gray' },
children: ['The route you are looking for does not exist.']
}
]
} as any;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

as any cast without justification.

As per coding guidelines, **/*.{ts,tsx}: "No any without an inline comment explaining why... No type assertions without an inline comment explaining why." The cast on Line 32 has no explanation, and its need signals the hand-built VNode object doesn't actually satisfy the VNode/VElement shape — worth constructing this via JSX (as the sibling implementation does) instead of raw object literals plus a silent cast.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/router/src/DefaultNotFound.tsx` at line 32, The hand-built VNode
object in DefaultNotFound uses an unexplained `as any` cast, which violates the
typing guideline and hides a shape mismatch. Update the component to construct
the element using JSX like the sibling implementation instead of a raw object
literal, and remove the type assertion entirely; if a cast is still unavoidable,
add an inline comment explaining why it is necessary.

Source: Coding guidelines

}
Comment on lines +4 to +33

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== files ==\n'
git ls-files 'packages/router/src/*NotFound*' 'packages/data/src/*NotFound*' 'packages/router/src/router.ts'

printf '\n== outline: router DefaultNotFound ==\n'
ast-grep outline packages/router/src/DefaultNotFound.tsx --view expanded || true

printf '\n== outline: data DefaultNotFound ==\n'
ast-grep outline packages/data/src/DefaultNotFound.tsx --view expanded || true

printf '\n== outline: router.ts ==\n'
ast-grep outline packages/router/src/router.ts --view expanded || true

printf '\n== router DefaultNotFound snippet ==\n'
sed -n '1,220p' packages/router/src/DefaultNotFound.tsx

printf '\n== data DefaultNotFound snippet ==\n'
sed -n '1,260p' packages/data/src/DefaultNotFound.tsx

printf '\n== router.ts relevant snippet ==\n'
sed -n '1,260p' packages/router/src/router.ts

printf '\n== keymap / notfound references ==\n'
rg -n "useKeymap|Backspace|ctrl\\+c|\\bq\\b|DefaultNotFound|NotFound" packages/router/src packages/data/src

Repository: Karanjot786/TermUI

Length of output: 19757


Restore the interactive not-found fallback in packages/router/src/DefaultNotFound.tsx. This is the default screen for unmatched routes, but it only renders static text. Move the Backspace/b and q/ctrl+c key handling here (or share the implementation from packages/data/src/DefaultNotFound.tsx) so users can navigate back or quit from the fallback screen.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/router/src/DefaultNotFound.tsx` around lines 4 - 33, Restore the
interactive fallback in DefaultNotFound by adding the Backspace/b and q/ctrl+c
key handling back into DefaultNotFound or by reusing the implementation from
packages/data/src/DefaultNotFound.tsx. The current DefaultNotFound(path) only
renders static text, so update the VNode it returns to include the key handlers
that navigate back or quit, while keeping the existing 404 UI and path display
intact.

17 changes: 13 additions & 4 deletions packages/router/src/RouterView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useState, useEffect, type VNode } from '@termuijs/jsx';
import { transition } from '@termuijs/motion';
import { Dim, Pos } from '@termuijs/core';
import { type Router, type NavigateEvent } from './router.js';
import { DefaultNotFound } from './DefaultNotFound.js';

// Custom position constraint that offsets by a percentage of the parent's width
class SlidePos extends Pos {
Expand All @@ -14,17 +15,25 @@ class SlidePos extends Pos {

export interface RouterViewProps {
router: Router;
/**
* Component to render when navigation targets an unregistered route.
* If not provided, DefaultNotFound is used.
*
* @example
* <RouterView router={router} notFound={(path) => <Text>No page at {path}</Text>} />
*/
notFound?: (path: string) => VNode;
}

export function RouterView({ router }: RouterViewProps) {
export function RouterView({ router, notFound }: RouterViewProps) {
const [screens, setScreens] = useState<{
previous: VNode | null;
current: VNode | null;
direction: 'push' | 'back' | 'replace' | 'forward';
progress: number;
}>({
previous: null,
current: router.current ? router.wrapScreen(router.current) : null,
current: router.current ? router.wrapScreen(router.current, notFound) : null,
direction: 'push',
progress: 1,
});
Expand Down Expand Up @@ -84,7 +93,7 @@ export function RouterView({ router }: RouterViewProps) {
router.events.off('navigate', onNav);
router.events.off('back', onBack);
};
}, [router]);
}, [router, notFound]);

const { previous, current, direction, progress } = screens;

Expand Down Expand Up @@ -113,4 +122,4 @@ export function RouterView({ router }: RouterViewProps) {
</box>
</box>
);
}
}
82 changes: 82 additions & 0 deletions packages/router/src/__tests__/not-found.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// packages/router/src/__tests__/not-found.test.ts

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

File must be .tsx, not .ts — JSX is unparseable in a .ts file.

Static analysis confirms this: parse errors on every JSX-containing line (9, 10, 24, 32, 43, 47, 61, 76). This file will fail to compile/run as-is.

🐛 Proposed fix

Rename not-found.test.tsnot-found.test.tsx.

Also applies to: 9-10, 22-81

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/router/src/__tests__/not-found.test.ts` at line 1, The test file
contains JSX, so it cannot be parsed as a .ts file; rename the not-found test
from .ts to .tsx so the JSX in the test body is valid. Update the test artifact
identified by not-found.test to use the .tsx extension and keep the existing
test content unchanged so the router test suite can compile and run.

Source: Linters/SAST tools

// Tests for Issue #2120 — 404 fallback behavior
import { describe, it, expect } from 'vitest';
import { render } from '@termuijs/testing';
import { Router } from '../Router';
import { Text } from '@termuijs/widgets';
Comment on lines +3 to +6

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

Tests should use the real Screen API per repo guidelines, not @termuijs/testing's render/getByText harness.

As per coding guidelines, packages/**/*.test.{ts,tsx} tests "must use the real Screen from @termuijs/core" and "Render widgets in tests using Screen, updateRect(), and render() methods." This suite instead uses a different render from @termuijs/testing with getByText/navigate/waitFor helpers, which doesn't follow the prescribed testing pattern.

Also applies to: 23-81

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/router/src/__tests__/not-found.test.ts` around lines 3 - 6, The
not-found test suite is using the testing harness from `@termuijs/testing` instead
of the required real Screen API. Update the Router test setup to use Screen from
`@termuijs/core`, and render widgets through Screen.updateRect() and
Screen.render() so the assertions exercise the real terminal rendering flow.
Replace any getByText/navigate/waitFor helpers with Screen-based inspection in
the Router and Text test cases.

Source: Coding guidelines


// ── Minimal test screens ──────────────────────────────────────────────────────
function HomeScreen() { return <Text>Home Screen</Text>; }
function AboutScreen() { return <Text>About Screen</Text>; }
// ─────────────────────────────────────────────────────────────────────────────

const baseConfig = {
routes: [
{ path: '/', component: HomeScreen },
{ path: '/about', component: AboutScreen },
],
};

describe('Router: notFound fallback (Issue #2120)', () => {

it('renders the matched component for a registered route', () => {
const t = render(
<Router config={{ ...baseConfig, initialRoute: '/' }} />
);
expect(t.getByText('Home Screen')).toBeTruthy();
t.unmount();
});

it('renders DefaultNotFound for an unregistered route', () => {
const t = render(
<Router config={{ ...baseConfig, initialRoute: '/does-not-exist' }} />
);
// DefaultNotFound must show the "Route Not Found" title
expect(t.getByText('Route Not Found')).toBeTruthy();
// And must display the unmatched path
expect(t.getByText('/does-not-exist')).toBeTruthy();
t.unmount();
});

it('renders a custom notFound component when config.notFound is provided', () => {
function CustomNotFound({ path }: { path: string }) {
return <Text>Custom 404 for {path}</Text>;
}

const t = render(
<Router config={{
...baseConfig,
initialRoute: '/unknown',
notFound: CustomNotFound,
}} />
);
expect(t.getByText('Custom 404 for /unknown')).toBeTruthy();
// Confirm the default NotFound is NOT shown
expect(() => t.getByText('Route Not Found')).toThrow();
t.unmount();
});

it('shows correct unmatched path in DefaultNotFound after navigate()', async () => {
const t = render(
<Router config={{ ...baseConfig, initialRoute: '/' }} />
);

// Navigate to a route that doesn't exist
t.navigate('/settings/profile/missing');

await t.waitFor(() => {
expect(t.getByText('Route Not Found')).toBeTruthy();
expect(t.getByText('/settings/profile/missing')).toBeTruthy();
});
t.unmount();
});

it('DefaultNotFound renders the help text with key hints', () => {
const t = render(
<Router config={{ ...baseConfig, initialRoute: '/not-real' }} />
);
// The help text line must be visible
expect(t.getByText('Press Backspace to go back · Press Q to quit')).toBeTruthy();
t.unmount();
});
});
35 changes: 30 additions & 5 deletions packages/router/src/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@ import type { RouteParams, RouteMeta, QueryParams } from './route.js';

export const RouterContext = createContext<Router | null>(null);

/**
* Returns the current router instance.
*/
export function useRouter(): Router | null {
return useContext(RouterContext);
}

/**
* Returns the current route parameters.
*/
Expand All @@ -19,6 +26,24 @@ export function useParams(): RouteParams {
return router.params;
}

/**
* Returns the current query parameters.
*/
export function useQuery(): QueryParams {
const router = useContext(RouterContext);
if (!router) {
return {};
}
return router.query;
}

/**
* Returns the current query parameters (alias for useQuery).
*/
export function useQueryParams(): QueryParams {
return useQuery();
}

/**
* Returns a function to trigger navigation.
*/
Expand Down Expand Up @@ -46,12 +71,12 @@ export function useRouteMeta(): RouteMeta {
}

/**
* Returns the current query parameters.
* Returns the current location path.
*/
export function useQueryParams(): QueryParams {
export function useLocation(): string {
const router = useContext(RouterContext);
if (!router) {
return {};
return '/';
}
return router.query;
}
return router.currentPath;
}
Loading
Loading