Skip to content

Commit

Permalink
Added History.toURL
Browse files Browse the repository at this point in the history
  • Loading branch information
smikhalevski committed Jul 10, 2024
1 parent 2e3014b commit f97a0a7
Show file tree
Hide file tree
Showing 23 changed files with 261 additions and 175 deletions.
2 changes: 1 addition & 1 deletion src/main/Link.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React, { HTMLAttributes, MouseEvent, ReactElement, useEffect } from 'react';
import { useNavigation } from './hooks';
import { LocationOptions, To } from './types';
import { useNavigation } from './useNavigation';

/**
* Props of the {@link Link} component.
Expand Down
2 changes: 1 addition & 1 deletion src/main/Navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export class Navigation {
*/
prefetch(to: To): boolean {
const location = toLocation(to);
const { routes, context } = this._router.props as RouterProps<unknown>;
const { routes, context } = this._router.props;

const routeMatches = matchRoutes(location.pathname, location.searchParams, routes);

Expand Down
8 changes: 4 additions & 4 deletions src/main/Outlet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@ NestedOutletControllerContext.displayName = 'NestedOutletControllerContext';

export interface OutletProps {
/**
* Children that are rendered if an {@link Outlet} doesn't have any content to render.
* A fallback that are rendered if an {@link Outlet} doesn't have any content to render.
*/
children?: ReactNode;
fallback?: ReactNode;
}

interface OutletState {
Expand Down Expand Up @@ -80,7 +80,7 @@ export class Outlet extends Component<OutletProps, OutletState> {

if (this._controller === null) {
this._prevController = null;
return this.props.children;
return this.props.fallback;
}

if (this.state.hasError) {
Expand All @@ -104,7 +104,7 @@ export class Outlet extends Component<OutletProps, OutletState> {
const prevController = controller.route?.['_pendingBehavior'] === 'fallback' ? controller : this._prevController;

if (prevController === null) {
return this.props.children;
return this.props.fallback;
}
controller = prevController;
}
Expand Down
6 changes: 5 additions & 1 deletion src/main/OutletController.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ReactNode } from 'react';
import { Router } from './Router';
import { isPromiseLike } from './utils';
import { NotFoundError } from './notFound';
import { Route } from './Route';
Expand All @@ -18,7 +19,10 @@ export class OutletController {
*/
protected _promise: Promise<void> | null = null;

constructor(readonly location: Location) {}
constructor(
readonly router: Router,
readonly location: Location
) {}

/**
* Suspends rendering if route content and data are still being loaded.
Expand Down
4 changes: 4 additions & 0 deletions src/main/PathnameAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,12 +106,14 @@ export class PathnameAdapter {
pathname += '/' + part;
continue;
}

if (
(params === undefined || (value = params[part]) === undefined || value === null || value === '') &&
(flag & FLAG_OPTIONAL) === FLAG_OPTIONAL
) {
continue;
}

if (typeof value !== 'string') {
throw new Error('Param must be a string: ' + part);
}
Expand Down Expand Up @@ -205,12 +207,14 @@ export function parsePathname(pathname: string): Template {
++i;
break;
}

if (stage === STAGE_PARAM || stage === STAGE_WILDCARD) {
flags[flags.length - 1] |= FLAG_OPTIONAL;
stage = STAGE_OPTIONAL;
++i;
break;
}

throw new SyntaxError('Unexpected optional flag at ' + i);

case 47 /* / */:
Expand Down
14 changes: 5 additions & 9 deletions src/main/Route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,12 @@ export class Route<
* @template Data Data loaded by a route.
* @template Context A context provided by a {@link Router} for a {@link RouteOptions.dataLoader}.
*/
constructor(parent: Parent, options: RouteOptions<Params, Data, Context>) {
const { paramsAdapter } = options;
constructor(parent: Parent, options: RouteOptions<Params, Data, Context> = {}) {
const { pathname = '/', paramsAdapter } = options;

this.parent = parent;

this._pathnameAdapter = new PathnameAdapter(options.pathname, options.isCaseSensitive);
this._pathnameAdapter = new PathnameAdapter(pathname, options.isCaseSensitive);
this._paramsAdapter = typeof paramsAdapter === 'function' ? { parse: paramsAdapter } : paramsAdapter;
this._pendingNode = memoizeNode(options.pendingFallback);
this._errorNode = memoizeNode(options.errorFallback);
Expand Down Expand Up @@ -142,12 +142,8 @@ export class Route<
*/
prefetch(params: this['_params'], context: Context): void {
for (let route: Route | null = this; route !== null; route = route.parent) {
try {
route['_contentRenderer']();
route['_dataLoader']?.(params, context);
} catch {
// noop
}
route['_contentRenderer']();
route['_dataLoader']?.(params, context);
}
}
}
Expand Down
40 changes: 19 additions & 21 deletions src/main/Router.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,12 @@
import React, { Component, createContext, ReactNode } from 'react';
import { Navigation } from './Navigation';
import { isArrayEqual } from './utils';
import React, { Component, ReactNode } from 'react';
import { matchRoutes } from './matchRoutes';
import { Navigation } from './Navigation';
import { NestedOutletControllerContext, Outlet } from './Outlet';
import { OutletController } from './OutletController';
import { Route } from './Route';
import { Location } from './types';

export const NavigationContext = createContext<Navigation | null>(null);

NavigationContext.displayName = 'NavigationContext';
import { NavigationContext } from './useNavigation';
import { isArrayEqual } from './utils';

/**
* Props of the {@link Router} component.
Expand All @@ -33,12 +30,12 @@ export interface RouterProps<Context> {
context: Context;

/**
* Triggered when a router location must be changed.
* Triggered when a new location must be added to a history stack.
*/
onPush?: (location: Location) => void;

/**
* Triggered when a router location must be changed.
* Triggered when a new location must replace the current history entry.
*/
onReplace?: (location: Location) => void;

Expand All @@ -62,12 +59,17 @@ export interface RouterProps<Context> {
/**
* Options of a {@link Router} that doesn't provide any context for a {@link RouteOptions.dataLoader}.
*/
export interface NoContextRouterProps extends Omit<RouterProps<void>, 'context'> {}
interface NoContextRouterProps extends Omit<RouterProps<void>, 'context'> {
/**
* An arbitrary context provided to {@link RouteOptions.dataLoader}.
*/
context?: undefined;
}

interface RouterState {
router: Router<any>;
routes: Route[];
controller: OutletController | null;
router: Router<any>;
}

/**
Expand All @@ -79,12 +81,8 @@ export class Router<Context = void> extends Component<NoContextRouterProps | Rou
/**
* @internal
*/
static getDerivedStateFromProps(props: RouterProps<any>, state: RouterState): Partial<RouterState> | null {
if (
state.controller !== null &&
state.controller.location === props.location &&
isArrayEqual(state.routes, props.routes)
) {
static getDerivedStateFromProps(props: RouterProps<unknown>, state: RouterState): Partial<RouterState> | null {
if (state.controller?.location === props.location && isArrayEqual(state.routes, props.routes)) {
return null;
}

Expand All @@ -93,7 +91,7 @@ export class Router<Context = void> extends Component<NoContextRouterProps | Rou
let controller: OutletController | null = null;

if (routeMatches === null) {
controller = new OutletController(props.location);
controller = new OutletController(state.router, props.location);
controller.node = props.notFoundFallback;

return {
Expand All @@ -103,7 +101,7 @@ export class Router<Context = void> extends Component<NoContextRouterProps | Rou
}

for (const routeMatch of routeMatches) {
const prevController = new OutletController(props.location);
const prevController = new OutletController(state.router, props.location);
prevController.load(routeMatch.route, routeMatch.params, props.context);
prevController.nestedController = controller;
controller = prevController;
Expand All @@ -115,7 +113,7 @@ export class Router<Context = void> extends Component<NoContextRouterProps | Rou
};
}

private readonly _navigation = new Navigation(this);
private _navigation = new Navigation(this);

/**
* @internal
Expand All @@ -124,9 +122,9 @@ export class Router<Context = void> extends Component<NoContextRouterProps | Rou
super(props);

this.state = {
router: this,
routes: props.routes,
controller: null,
router: this,
};
}

Expand Down
9 changes: 6 additions & 3 deletions src/main/createRoute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { RouteOptions } from './types';
* @template Context A context provided by a {@link Router} for a {@link RouteOptions.dataLoader}.
*/
export function createRoute<Params extends object | void = void, Data = void, Context = any>(
options: RouteOptions<Params, Data, Context>
options?: RouteOptions<Params, Data, Context>
): Route<null, Params, Data, Context>;

/**
Expand All @@ -25,9 +25,12 @@ export function createRoute<Params extends object | void = void, Data = void, Co
*/
export function createRoute<Parent extends Route, Params extends object | void = void, Data = void>(
parent: Parent,
options: RouteOptions<Params, Data, Parent['_context']>
options?: RouteOptions<Params, Data, Parent['_context']>
): Route<Parent, Params, Data, Parent['_context']>;

export function createRoute(parentOrOptions: Route | RouteOptions<any, any, any>, options?: any) {
export function createRoute(
parentOrOptions?: Route | RouteOptions<any, any, any>,
options?: RouteOptions<any, any, any>
) {
return parentOrOptions instanceof Route ? new Route(parentOrOptions, options) : new Route(null, parentOrOptions);
}
Original file line number Diff line number Diff line change
@@ -1,34 +1,23 @@
import { PubSub } from 'parallel-universe';
import { Dict, History } from './types';
import { toLocation } from '../utils';
import { History, SearchParamsAdapter } from './types';
import { urlSearchParamsAdapter } from './urlSearchParamsAdapter';
import { parseURL, toLocation, toURL } from './utils';
import { parseURL, toURL } from './utils';

/**
* Extracts params from a URL search string and stringifies them back.
* Options of {@link createBrowserHistory}.
*/
export interface SearchParamsAdapter {
export interface BrowserHistoryOptions {
/**
* Extract params from a URL search string.
* A URL base used by {@link History.toURL}.
*
* @param search The URL search string to extract params from.
* @default window.location.origin
*/
parse(search: string): Dict;
base?: URL | string;

/**
* Stringifies params as a search string.
*
* @param params Params to stringify.
*/
stringify(params: Dict): string;
}

/**
* Options of {@link createBrowserHistory}.
*/
export interface BrowserHistoryOptions {
/**
* An adapter that extracts params from a URL search string and stringifies them back. By default,
* an adapter that relies on {@link !URLSearchParams} is used.
* An adapter that extracts params from a URL search string and stringifies them back. By default, an adapter that
* relies on {@link !URLSearchParams} is used.
*/
searchParamsAdapter?: SearchParamsAdapter;
}
Expand All @@ -38,9 +27,9 @@ export interface BrowserHistoryOptions {
*
* @param options History options.
*/
export function createBrowserHistory(options?: BrowserHistoryOptions): History {
export function createBrowserHistory(options: BrowserHistoryOptions = {}): History {
const { base: defaultBase = window.location.origin, searchParamsAdapter = urlSearchParamsAdapter } = options;
const pubSub = new PubSub();
const searchParamsAdapter = options?.searchParamsAdapter || urlSearchParamsAdapter;

let location = parseURL(window.location.href, searchParamsAdapter);

Expand All @@ -54,6 +43,10 @@ export function createBrowserHistory(options?: BrowserHistoryOptions): History {
return location;
},

toURL(location, base = defaultBase) {
return new URL(toURL(location, searchParamsAdapter), base);
},

push(to) {
location = toLocation(to);
history.pushState(location.state, '', toURL(location, searchParamsAdapter));
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { PubSub } from 'parallel-universe';
import { History, Location } from './types';
import { toLocation } from './utils';
import { Location } from '../types';
import { History, SearchParamsAdapter } from './types';
import { urlSearchParamsAdapter } from './urlSearchParamsAdapter';
import { toLocation } from '../utils';
import { toURL } from './utils';

/**
* Options of {@link createMemoryHistory}.
Expand All @@ -10,6 +13,19 @@ export interface MemoryHistoryOptions {
* A non-empty array of initial history entries.
*/
initialEntries: Location[];

/**
* A URL base used by {@link History.toURL}.
*
* If omitted a base should be specified with each {@link History.toURL} call, otherwise an error is thrown.
*/
base?: URL | string;

/**
* An adapter that extracts params from a URL search string and stringifies them back. By default, an adapter that
* relies on {@link !URLSearchParams} is used.
*/
searchParamsAdapter?: SearchParamsAdapter;
}

/**
Expand All @@ -18,6 +34,7 @@ export interface MemoryHistoryOptions {
* @param options History options.
*/
export function createMemoryHistory(options: MemoryHistoryOptions): History {
const { base: defaultBase, searchParamsAdapter = urlSearchParamsAdapter } = options;
const pubSub = new PubSub();
const entries = options.initialEntries.slice(0);

Expand All @@ -32,6 +49,13 @@ export function createMemoryHistory(options: MemoryHistoryOptions): History {
return entries[cursor];
},

toURL(location, base = defaultBase) {
if (base === undefined) {
throw new Error('No base URL provided');
}
return new URL(toURL(location, searchParamsAdapter), base);
},

push(to) {
entries.push(toLocation(to));
pubSub.publish();
Expand Down
Loading

0 comments on commit f97a0a7

Please sign in to comment.