Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(react-router): add type safety to useActionData & useLoaderData hooks #9670

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
108 changes: 108 additions & 0 deletions packages/react-router/lib/Jsonify.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/**
* @see https://github.com/sindresorhus/type-fest/blob/main/source/jsonify.d.ts
*/

declare const emptyObjectSymbol: unique symbol;
type EmptyObject = { [emptyObjectSymbol]?: never };

type IsAny<T> = 0 extends 1 & T ? true : false;

type JsonArray = JsonValue[];
type JsonObject = { [Key in string]: JsonValue } & {
[Key in string]?: JsonValue | undefined;
};
type JsonPrimitive = string | number | boolean | null;
type JsonValue = JsonPrimitive | JsonObject | JsonArray;

type NegativeInfinity = -1e999;
type PositiveInfinity = 1e999;

type TypedArray =
| Int8Array
| Uint8Array
| Uint8ClampedArray
| Int16Array
| Uint16Array
| Int32Array
| Uint32Array
| Float32Array
| Float64Array
| BigInt64Array
| BigUint64Array;

type BaseKeyFilter<Type, Key extends keyof Type> = Key extends symbol
? never
: Type[Key] extends symbol
? never
: [(...args: any[]) => any] extends [Type[Key]]
? never
: Key;
type FilterDefinedKeys<T extends object> = Exclude<
{
[Key in keyof T]: IsAny<T[Key]> extends true
? Key
: undefined extends T[Key]
? never
: T[Key] extends undefined
? never
: BaseKeyFilter<T, Key>;
}[keyof T],
undefined
>;
type FilterOptionalKeys<T extends object> = Exclude<
{
[Key in keyof T]: IsAny<T[Key]> extends true
? never
: undefined extends T[Key]
? T[Key] extends undefined
? never
: BaseKeyFilter<T, Key>
: never;
}[keyof T],
undefined
>;
type UndefinedToOptional<T extends object> = {
// Property is not a union with `undefined`, keep it as-is.
[Key in keyof Pick<T, FilterDefinedKeys<T>>]: T[Key];
} & {
// Property _is_ a union with defined value. Set as optional (via `?`) and remove `undefined` from the union.
[Key in keyof Pick<T, FilterOptionalKeys<T>>]?: Exclude<T[Key], undefined>;
};

// Note: The return value has to be `any` and not `unknown` so it can match `void`.
type NotJsonable = ((...args: any[]) => any) | undefined | symbol;

type JsonifyTuple<T extends [unknown, ...unknown[]]> = {
[Key in keyof T]: T[Key] extends NotJsonable ? null : Jsonify<T[Key]>;
};

type FilterJsonableKeys<T extends object> = {
[Key in keyof T]: T[Key] extends NotJsonable ? never : Key;
}[keyof T];

type JsonifyObject<T extends object> = {
[Key in keyof Pick<T, FilterJsonableKeys<T>>]: Jsonify<T[Key]>;
};

// prettier-ignore
export type Jsonify<T> =
IsAny<T> extends true ? any
: T extends PositiveInfinity | NegativeInfinity ? null
: T extends JsonPrimitive ? T
// Instanced primitives are objects
: T extends Number ? number
: T extends String ? string
: T extends Boolean ? boolean
: T extends Map<any, any> | Set<any> ? EmptyObject
: T extends TypedArray ? Record<string, number>
: T extends NotJsonable ? never // Non-JSONable type union was found not empty
// Any object with toJSON is special case
: T extends { toJSON(): infer J } ?
(() => J) extends () => JsonValue // Is J assignable to JsonValue?
? J // Then T is Jsonable and its Jsonable value is J
: Jsonify<J> // Maybe if we look a level deeper we'll find a JsonValue
: T extends [] ? []
: T extends [unknown, ...unknown[]] ? JsonifyTuple<T>
: T extends ReadonlyArray<infer U> ? Array<U extends NotJsonable ? null : Jsonify<U>>
: T extends object ? JsonifyObject<UndefinedToOptional<T>> // JsonifyObject recursive call for its children
: never; // Otherwise any other non-object is removed
10 changes: 8 additions & 2 deletions packages/react-router/lib/hooks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ import {
RouteErrorContext,
AwaitContext,
} from "./context";
import type { SerializeFrom } from "./serialize";
import type { ArbitraryFunction } from "./serialize";

/**
* Returns the full href for the given "to" value. This is useful for building
Expand Down Expand Up @@ -799,7 +801,9 @@ export function useMatches() {
/**
* Returns the loader data for the nearest ancestor Route loader
*/
export function useLoaderData(): unknown {
export function useLoaderData<
T extends ArbitraryFunction = () => unknown
>(): SerializeFrom<T> {
let state = useDataRouterState(DataRouterStateHook.UseLoaderData);
let routeId = useCurrentRouteId(DataRouterStateHook.UseLoaderData);

Expand All @@ -823,7 +827,9 @@ export function useRouteLoaderData(routeId: string): unknown {
/**
* Returns the action data for the nearest ancestor Route action
*/
export function useActionData(): unknown {
export function useActionData<
T extends ArbitraryFunction = () => unknown
>(): SerializeFrom<T> {
let state = useDataRouterState(DataRouterStateHook.UseActionData);

let route = React.useContext(RouteContext);
Expand Down
12 changes: 12 additions & 0 deletions packages/react-router/lib/serialize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { TypedResponse } from "@remix-run/router";
import type { Jsonify } from "./jsonify";

export type ArbitraryFunction = (...args: any[]) => unknown;

export type SerializeFrom<T extends ArbitraryFunction> = Jsonify<
T extends (...args: any[]) => infer Output
? Awaited<Output> extends TypedResponse<infer U>
? U
: Awaited<Output>
: Awaited<T>
>;
1 change: 1 addition & 0 deletions packages/router/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export type {
RedirectFunction,
ShouldRevalidateFunction,
V7_FormMethod,
TypedResponse,
} from "./utils";

export {
Expand Down
19 changes: 15 additions & 4 deletions packages/router/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1197,14 +1197,23 @@ export const normalizeSearch = (search: string): string =>
export const normalizeHash = (hash: string): string =>
!hash || hash === "#" ? "" : hash.startsWith("#") ? hash : "#" + hash;

export type JsonFunction = <Data>(
export type JsonFunction = <Data extends unknown>(
data: Data,
init?: number | ResponseInit
) => Response;
) => TypedResponse<Data>;

export type TypedResponse<T extends unknown = unknown> = Omit<
Response,
"json"
> & {
json(): Promise<T>;
};

/**
* This is a shortcut for creating `application/json` responses. Converts `data`
* to JSON and sets the `Content-Type` header.
*
* @see https://reactrouter.com/fetch/json
*/
export const json: JsonFunction = (data, init = {}) => {
let responseInit = typeof init === "number" ? { status: init } : init;
Expand Down Expand Up @@ -1418,11 +1427,13 @@ export const defer: DeferFunction = (data, init = {}) => {
export type RedirectFunction = (
url: string,
init?: number | ResponseInit
) => Response;
) => TypedResponse<never>;

/**
* A redirect response. Sets the status code and the `Location` header.
* Defaults to "302 Found".
*
* @see https://reactrouter.com/fetch/redirect
*/
export const redirect: RedirectFunction = (url, init = 302) => {
let responseInit = init;
Expand All @@ -1438,7 +1449,7 @@ export const redirect: RedirectFunction = (url, init = 302) => {
return new Response(null, {
...responseInit,
headers,
});
}) as TypedResponse<never>;
};

/**
Expand Down