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
13 changes: 4 additions & 9 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,5 @@
SYSTEM RULE: RESPECT AGENTS.md You must treat 'AGENTS.md' exactly like
'CLAUDE.md' with the following hierarchy rules.
# CLAUDE.md

1. **Parent/Current (Startup)**: IMMEDIATELY upon starting this session, use
your tools to check the current directory AND all parent directories (up to
the git root) for a file named 'AGENTS.md'. If found, read them.
2. **Child (On-Demand)**: Before editing or reading files in a subdirectory,
check if an 'AGENTS.md' exists in that subdirectory. If it does, read it
first to update your context.
3. **Priority**: Treat instructions in 'AGENTS.md' as binding system rules.
This repository keeps its agent guidance in `AGENTS.md`. Read the `AGENTS.md` in
this directory — and any `AGENTS.md` in a subdirectory you're working in — and
follow it as you would this file.
8 changes: 8 additions & 0 deletions src/_client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,16 @@ export function useJuniperContext(): RouterContextProvider {
return context;
}

/**
* Flags indicating which server-side handlers a route file exports.
*
* Set by the build system so the client knows whether to call the route's
* server loader/action over the network during navigation.
*/
export interface ServerFlags {
/** Whether the route exports a server-side `loader`. */
loader?: boolean;
/** Whether the route exports a server-side `action`. */
action?: boolean;
}

Expand Down
33 changes: 33 additions & 0 deletions src/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,22 +139,41 @@ const activeBuilders = new Set<Builder>();
* ```
*/
export class Builder implements AsyncDisposable {
/** Absolute path to the project root; every other path resolves against it. */
readonly projectRoot: string;
/** Absolute path to the routes directory scanned for file-based routes. */
readonly routesPath: string;
/** Absolute path to the public directory served as static assets. */
readonly publicPath: string;
/** Absolute path to the resolved `deno.json`/`deno.jsonc` config file. */
readonly configPath: string;
/** Absolute path to the generated server entrypoint (`main.ts`). */
readonly serverPath: string;
/** Absolute path to the generated client entrypoint (`main.tsx`). */
readonly clientPath: string;
/** Absolute path to the application's client entry (`main.tsx`). */
readonly entryPoint: string;
/** Paths watched for changes by the dev server; defaults to the project root. */
readonly watchPaths: string | string[];
/**
* Absolute paths pruned from the watch tree.
* @see {@linkcode Builder.resolveWatchPaths}
*/
readonly ignorePaths: string[];
/** Absolute output directory for built assets (`public/build`). */
readonly outdir: string;
/** All esbuild entry points: any extra entries plus the main client entry. */
readonly entryPoints: string[];
/** Whether build output is written to disk; `false` is used in tests. */
protected write: boolean;
/** Extra esbuild plugins inserted between the Deno resolver and loader. */
protected plugins: esbuild.Plugin[];
/** The active esbuild incremental build context, once a build has started. */
protected context?: esbuild.BuildContext;
/** Backing flag for {@linkcode Builder.isBuilding}. */
protected _isBuilding: boolean;

/** Whether a build or rebuild is currently in progress. */
get isBuilding(): boolean {
return this._isBuilding;
}
Expand Down Expand Up @@ -268,6 +287,10 @@ export class Builder implements AsyncDisposable {
await delay(10);
}
}
/**
* Disposes the builder via explicit resource management (`await using`),
* delegating to {@linkcode Builder.dispose}.
*/
[Symbol.asyncDispose](): Promise<void> {
return this.dispose();
}
Expand Down Expand Up @@ -524,6 +547,16 @@ export const client = new Client(${routesConfigString});
});
}

/**
* Rebuilds the application using the esbuild context from a prior
* {@linkcode Builder.build}. Use this for incremental rebuilds (e.g. from the
* dev server); it throws if no build has started or one is already in progress.
*
* @param options - Which generated entrypoints to regenerate before rebuilding.
* @param options.server - Regenerate the server entrypoint (`main.ts`).
* @param options.client - Regenerate the client entrypoint (`main.tsx`).
* @returns A promise that resolves to the esbuild build results.
*/
rebuild(options: {
server?: boolean;
client?: boolean;
Expand Down
11 changes: 9 additions & 2 deletions src/client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,14 @@ import { env } from "./utils/_env.ts";
export type { HydrationData, ServerFlags };

/** Loads a non-root route module on demand. */
type RouteModuleLoader = () => Promise<RouteModule>;
export type RouteModuleLoader = () => Promise<RouteModule>;

/** Loads the root route module on demand. */
type RootRouteModuleLoader = () => Promise<RootRouteModule>;
export type RootRouteModuleLoader = () => Promise<RootRouteModule>;

/** A client route definition used by the generated `main.tsx`. */
export interface ClientRoute {
/** The route's URL path segment. */
path: string;
/**
* The route's module.
Expand Down Expand Up @@ -118,6 +119,12 @@ export class Client {
/** Props to apply to the `<html>` element, from root route's htmlProps export. */
htmlProps?: HtmlProps;

/**
* Builds the client route tree from a root route, ready to
* {@linkcode Client.hydrate}.
*
* @param rootRoute - The root client route, typically the generated `main.tsx`.
*/
constructor(rootRoute: RootClientRoute) {
this.rootRoute = rootRoute;
this.routeFileMap = new Map();
Expand Down
36 changes: 34 additions & 2 deletions src/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -348,9 +348,17 @@ export interface RouteLoaderArgs<
Params extends AnyParams = AnyParams,
LoaderData = unknown,
> {
/** The request-scoped router context, shared across middleware, loaders, and actions. */
context: RouterContextProvider;
/** The matched route params. */
params: Params;
/** The incoming request. */
request: Request;
/**
* Calls the route's server loader from a client loader, resolving its data
* (or a `Response`). Only meaningful in a client loader paired with a server
* loader; on the server it resolves the loader's own data.
*/
serverLoader: () => LoaderData | Response | Promise<LoaderData | Response>;
}

Expand Down Expand Up @@ -405,9 +413,17 @@ export interface RouteActionArgs<
Params extends AnyParams = AnyParams,
ActionData = unknown,
> {
/** The request-scoped router context, shared across middleware, loaders, and actions. */
context: RouterContextProvider;
/** The matched route params. */
params: Params;
/** The incoming request. */
request: Request;
/**
* Calls the route's server action from a client action, resolving its data
* (or a `Response`). Only meaningful in a client action paired with a server
* action; on the server it resolves the action's own data.
*/
serverAction: () => ActionData | Response | Promise<ActionData | Response>;
}

Expand All @@ -432,8 +448,11 @@ export interface RouteActionArgs<
export interface RouteMiddlewareArgs<
Params extends AnyParams = AnyParams,
> {
/** The request-scoped router context, shared across middleware, loaders, and actions. */
context: RouterContextProvider;
/** The matched route params. */
params: Params;
/** The incoming request. */
request: Request;
}

Expand Down Expand Up @@ -651,8 +670,11 @@ export interface ErrorBoundaryProps<
resetErrorBoundary: () => void;
}

/** A React component used for a route. */
type BivariantComponent<Props> = {
/**
* A React component type for a route export, with props compared bivariantly
* so route components stay assignable regardless of prop variance.
*/
export type BivariantComponent<Props> = {
bivarianceHack(props: Props): ReactElement | null;
}["bivarianceHack"];

Expand Down Expand Up @@ -1060,6 +1082,16 @@ export interface RouteModule<
*/
export type HtmlProps = React.HTMLAttributes<HTMLHtmlElement>;

/**
* The module shape for the root route (`routes/main.tsx`).
*
* Extends {@linkcode RouteModule} with {@linkcode RootRouteModule.htmlProps} for
* configuring the document's `<html>` element.
*
* @template Params - The type of route params. Defaults to `AnyParams`.
* @template LoaderData - The type of loader data. Defaults to `unknown`.
* @template ActionData - The type of action data. Defaults to `unknown`.
*/
export interface RootRouteModule<
Params extends AnyParams = AnyParams,
LoaderData = unknown,
Expand Down
17 changes: 17 additions & 0 deletions src/utils/testing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,17 +152,34 @@ export function simulateEnvironment<T extends void | Promise<void>>(
});
}

/**
* A route definition for {@linkcode createRoutesStub}.
*
* Extends {@linkcode RouteModule} with the routing metadata the stub needs to
* place the route in the test router.
*/
export interface RouteStub extends RouteModule<AnyParams, unknown, unknown> {
/** The route's URL path segment. */
path?: string;
/** Flags marking which server-side handlers the route simulates. */
serverFlags?: ServerFlags;
/** Overrides the id used to match this route in the test router. */
routeId?: string;
}

/**
* Props for the component returned by {@linkcode createRoutesStub}.
*/
export interface RoutesStubProps {
/** The initial history entries (URLs) the stubbed router starts at. */
initialEntries?: string[];
/** Initial loader/action data used to hydrate the stubbed routes. */
hydrationData?: HydrationState;
}

/**
* Options for {@linkcode createRoutesStub}.
*/
export interface CreateRoutesStubOptions {
/**
* A function to set up initial context values before rendering.
Expand Down
Loading