Skip to content
Closed
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
27 changes: 25 additions & 2 deletions src/router.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { h, createContext, cloneElement, toChildArray } from 'preact';
import { h, createContext, cloneElement, toChildArray, Fragment } from 'preact';
import { useContext, useMemo, useReducer, useLayoutEffect, useRef } from 'preact/hooks';

/**
Expand Down Expand Up @@ -157,7 +157,30 @@ export function Router(props) {
didSuspend.current = false;

let pathRoute, defaultRoute, matchProps;
toChildArray(props.children).some((/** @type {VNode<any>} */ vnode) => {

// Flatten all routes from nested children first
const flattenRoutes = (children, routes = []) => {
toChildArray(children).forEach((/** @type {VNode<any>} */ vnode) => {
// Skip null/undefined/false children
if (!vnode) return;

// Check if this is a route component (has path or default prop)
if (vnode.props && (vnode.props.path != null || vnode.props.default != null)) {
routes.push(vnode);
}
// If it's a Fragment or wrapper component, recurse into its children
else if (vnode.props && vnode.props.children) {
flattenRoutes(vnode.props?.children, routes);
}
});
return routes;
};

// Get all routes from potentially nested structure
const allRoutes = flattenRoutes(props.children);

// Now process routes as before
allRoutes.some((/** @type {VNode<any>} */ vnode) => {
const matches = exec(rest, vnode.props.path, (matchProps = { ...vnode.props, path: rest, query, params, rest: '' }));
if (matches) return (pathRoute = cloneElement(vnode, matchProps));
if (vnode.props.default) defaultRoute = cloneElement(vnode, matchProps);
Expand Down
94 changes: 94 additions & 0 deletions test/router.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1076,4 +1076,98 @@ describe('hydration', () => {

options.__b = oldOptionsVnode;
});

it('should support routes nested in Fragments and wrapper components', async () => {
const Home = () => <h1>Home</h1>;
const About = () => <h1>About</h1>;
const Contact = () => <h1>Contact</h1>;
const Nested = () => <h1>Nested</h1>;

render(
<LocationProvider>
<Router>
{/* Routes in a Fragment */}
<Fragment>
<Route path="/" component={Home} default />
<Route path="/about" component={About} />
</Fragment>

{/* Routes nested in a wrapper div */}
<div className="route-wrapper">
<Route path="/contact" component={Contact} />
</div>

{/* Deeply nested routes */}
<Fragment>
<div>
<Fragment>
<Route path="/nested" component={Nested} />
</Fragment>
</div>
</Fragment>
</Router>
<ShallowLocation />
</LocationProvider>,
scratch
);

// Test default route
expect(scratch.querySelector('h1').textContent).to.equal('Home');

// Test route in Fragment
loc.route('/about');
await sleep(1);
expect(scratch.querySelector('h1').textContent).to.equal('About');

// Test route in wrapper div
loc.route('/contact');
await sleep(1);
expect(scratch.querySelector('h1').textContent).to.equal('Contact');

// Test deeply nested route
loc.route('/nested');
await sleep(1);
expect(scratch.querySelector('h1').textContent).to.equal('Nested');
});

it('should support routes with mixed nesting and fragments', async () => {
const A = () => <h1>A</h1>;
const B = () => <h1>B</h1>;
const C = () => <h1>C</h1>;
const Default = () => <h1>Default</h1>;

render(
<LocationProvider>
<Router>
<Route path="/a" component={A} />
<Fragment>
<Route path="/b" component={B} />
<div>
<Route path="/c" component={C} />
</div>
</Fragment>
<Route default component={Default} />
</Router>
<ShallowLocation />
</LocationProvider>,
scratch
);

// Test mixed routes work correctly
loc.route('/a');
await sleep(1);
expect(scratch.querySelector('h1').textContent).to.equal('A');

loc.route('/b');
await sleep(1);
expect(scratch.querySelector('h1').textContent).to.equal('B');

loc.route('/c');
await sleep(1);
expect(scratch.querySelector('h1').textContent).to.equal('C');

loc.route('/unknown');
await sleep(1);
expect(scratch.querySelector('h1').textContent).to.equal('Default');
});
})