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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@olliethedev/yar",
"version": "1.0.8",
"version": "1.0.9",
"packageManager": "pnpm@10.14.0",
"description": "Pluggable router for modern react frameworks",
"type": "module",
Expand Down
165 changes: 159 additions & 6 deletions src/__tests__/router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,12 +182,14 @@ describe("createRoute", () => {

const route = createRoute("/with-extra", () => ({
PageComponent: MockComponent,
extra: extraData,
extra: () => extraData,
}));

const result = route();
expect(result.PageComponent).toBe(MockComponent);
expect(result.extra).toEqual(extraData);
if (result.extra) {
expect(result.extra()).toEqual(extraData);
}
});

it("should support typed extra field", () => {
Expand All @@ -196,11 +198,13 @@ describe("createRoute", () => {

const route = createRoute("/typed-extra", () => ({
PageComponent: MockComponent,
extra: extraData,
extra: () => extraData,
}));

const result = route();
expect(result.extra).toEqual(extraData);
if (result.extra) {
expect(result.extra()).toEqual(extraData);
}
});
});

Expand Down Expand Up @@ -493,15 +497,17 @@ describe("createRouter", () => {
const routes = {
admin: createRoute("/admin", () => ({
PageComponent: MockComponent,
extra: extraData,
extra: () => extraData,
})),
};

const router = createRouter(routes);
const match = router.getRoute("/admin");

expect(match).toBeDefined();
expect(match?.extra).toEqual(extraData);
if (match?.extra) {
expect(match.extra()).toEqual(extraData);
}
});
});

Expand Down Expand Up @@ -779,4 +785,151 @@ describe("Edge cases", () => {
]);
}
});

it("should infer extra function types correctly - no params", () => {
const route = createRoute("/extra-no-params", () => ({
PageComponent: MockComponent,
extra: () => ({ analytics: "tracking-id-123" }),
}));

const router = createRouter({ route });
const match = router.getRoute("/extra-no-params");

expect(match?.extra).toBeDefined();
if (match?.extra) {
const extraData = match.extra();
expect(extraData).toEqual({ analytics: "tracking-id-123" });
}
});

it("should infer extra function types correctly - with params", () => {
const route = createRoute("/extra-custom", () => ({
PageComponent: MockComponent,
extra: (userId: string, sessionId: number) => ({
tracking: { userId, sessionId },
timestamp: Date.now(),
}),
}));

const router = createRouter({ route });
const match = router.getRoute("/extra-custom");

expect(match?.extra).toBeDefined();
if (match?.extra) {
const extraData = match.extra("user-123", 456);
expect(extraData.tracking).toEqual({
userId: "user-123",
sessionId: 456,
});
expect(extraData.timestamp).toBeGreaterThan(0);
}
});

it("should support async extra functions", async () => {
const route = createRoute("/extra-async", () => ({
PageComponent: MockComponent,
extra: async (apiKey: string) => {
// Simulate async operation
await new Promise((resolve) => setTimeout(resolve, 10));
return {
apiKey,
validated: true,
timestamp: Date.now(),
};
},
}));

const router = createRouter({ route });
const match = router.getRoute("/extra-async");

expect(match?.extra).toBeDefined();
if (match?.extra) {
const extraData = await match.extra("secret-key");
expect(extraData.apiKey).toBe("secret-key");
expect(extraData.validated).toBe(true);
expect(extraData.timestamp).toBeGreaterThan(0);
}
});

// Type safety test - this DOES cause a compile error now! ✅
it("should enforce meta shape at compile time", () => {
const route = createRoute("/invalid-meta", () => ({
PageComponent: MockComponent,
// @ts-expect-error - This correctly fails: invalid meta shape
meta: () => [{ invalid: "property", notAllowed: true }],
}));

expect(route).toBeDefined();
});

it("should support route-level metadata", () => {
const homeRoute = createRoute(
"/",
() => ({
PageComponent: MockComponent,
}),
undefined,
{ isStatic: true, category: "public" },
);

const userRoute = createRoute(
"/user/:id",
() => ({
PageComponent: MockComponent,
}),
undefined,
{ isStatic: false, category: "user", requiresAuth: true },
);

expect(homeRoute.meta).toEqual({ isStatic: true, category: "public" });
expect(userRoute.meta).toEqual({
isStatic: false,
category: "user",
requiresAuth: true,
});
});

it("should allow filtering routes by metadata", () => {
const routes = {
home: createRoute(
"/",
() => ({ PageComponent: MockComponent }),
undefined,
{ isStatic: true },
),
about: createRoute(
"/about",
() => ({ PageComponent: MockComponent }),
undefined,
{ isStatic: true },
),
user: createRoute(
"/user/:id",
() => ({ PageComponent: MockComponent }),
undefined,
{ isStatic: false, requiresAuth: true },
),
};

const router = createRouter(routes);

// Filter static routes without calling handlers
const staticRoutes = Object.entries(router.routes).filter(
([_, route]) => route.meta?.isStatic === true,
);

expect(staticRoutes).toHaveLength(2);
expect(staticRoutes.map(([name]) => name)).toEqual(["home", "about"]);

// Filter routes requiring auth
const authRoutes = Object.entries(router.routes).filter(
([_, route]) =>
route.meta &&
"requiresAuth" in route.meta &&
route.meta.requiresAuth === true,
);

expect(authRoutes).toHaveLength(1);
expect(authRoutes[0][0]).toBe("user");
});
});
43 changes: 21 additions & 22 deletions src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,28 +8,27 @@ import type {
InferQuery,
InputContext,
Route,
RouteMeta,
RouteOptions,
RouterConfig,
} from "./types";

// Helper type to validate meta function return type
type MetaArray = Array<React.JSX.IntrinsicElements["meta"] | undefined>;
type MetaReturnType = MetaArray | Promise<MetaArray>;

type HandlerReturn<
ComponentProps,
LoaderData,
Extra = unknown,
MetaFn extends (
...args: any[]
) =>
| Array<React.JSX.IntrinsicElements["meta"] | undefined>
| Promise<Array<React.JSX.IntrinsicElements["meta"] | undefined>> = () =>
| Array<React.JSX.IntrinsicElements["meta"] | undefined>
| Promise<Array<React.JSX.IntrinsicElements["meta"] | undefined>>,
ExtraFn extends (...args: any[]) => any | Promise<any> = () => any,
MetaFn extends (...args: any[]) => MetaReturnType = () => MetaArray,
> = {
PageComponent?: ComponentType<ComponentProps>;
LoadingComponent?: ComponentType<ComponentProps>;
ErrorComponent?: ComponentType<ComponentProps>;
loader?: () => LoaderData | Promise<LoaderData>;
meta?: MetaFn;
extra?: Extra;
extra?: ExtraFn;
};

/**
Expand All @@ -39,14 +38,16 @@ type HandlerReturn<
* @template Options - Route options including query parameter validation schema
* @template LoaderData - The type of data returned by the optional loader function
* @template ComponentProps - The props type for the React component
* @template Extra - The type of extra data returned by the handler
* @template MetaFn - The type of the meta function
* @template ExtraFn - The type of the extra function
* @template MetaFn - The type of the meta function (must return valid React meta elements)
* @template Meta - The type of route-level metadata
*
* @param {Path} path - The route path pattern with optional dynamic segments
* @param {Function} handler - Handler function that receives route context (params, query) and returns component, loader, and meta configuration
* @param {Options} [options] - Optional configuration including query parameter validation schema
* @param {Meta} [meta] - Optional route-level metadata for filtering/categorization without executing the handler
*
* @returns {Function} A route handler function with path and options attached
* @returns {Function} A route handler function with path, options, and meta attached
*
* @example
* ```ts
Expand All @@ -57,7 +58,8 @@ type HandlerReturn<
* loader: () => fetchUser(params.id),
* meta: (data) => [{ name: "title", content: `User ${data.name}` }]
* }),
* { query: z.object({ tab: z.string().optional() }) }
* { query: z.object({ tab: z.string().optional() }) },
* { isStatic: false, requiresAuth: true }
* );
* ```
*/
Expand All @@ -66,21 +68,17 @@ export function createRoute<
Options extends RouteOptions,
LoaderData,
ComponentProps,
Extra = unknown,
MetaFn extends (
...args: any[]
) =>
| Array<React.JSX.IntrinsicElements["meta"] | undefined>
| Promise<Array<React.JSX.IntrinsicElements["meta"] | undefined>> = () =>
| Array<React.JSX.IntrinsicElements["meta"] | undefined>
| Promise<Array<React.JSX.IntrinsicElements["meta"] | undefined>>,
ExtraFn extends (...args: any[]) => any | Promise<any> = () => any,
MetaFn extends (...args: any[]) => MetaReturnType = () => MetaArray,
Meta extends RouteMeta = RouteMeta,
>(
path: Path,
handler: (context: {
params: InferParam<Path>;
query: InferQuery<Options> | undefined;
}) => HandlerReturn<ComponentProps, LoaderData, Extra, MetaFn>,
}) => HandlerReturn<ComponentProps, LoaderData, ExtraFn, MetaFn>,
options?: Options,
meta?: Meta,
) {
type Context = InputContext<Path, Options>;
const internalHandler = (
Expand All @@ -97,6 +95,7 @@ export function createRoute<
};
internalHandler.options = options;
internalHandler.path = path;
internalHandler.meta = meta;
return internalHandler;
}

Expand Down
4 changes: 4 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,17 @@ export interface RouteOptions {
query?: StandardSchemaV1;
}

export type RouteMeta = Record<string, any>;

export type Route<
Path extends string = string,
Options extends RouteOptions = RouteOptions,
Handler extends (inputCtx: any) => any = (inputCtx: any) => any,
Meta extends RouteMeta = RouteMeta,
> = Handler & {
options?: Options;
path: Path;
meta?: Meta;
};

export type InputContext<
Expand Down