Skip to content

Commit

Permalink
Added SlotValue
Browse files Browse the repository at this point in the history
  • Loading branch information
smikhalevski committed Jul 17, 2024
1 parent a7e177a commit bbdc4a2
Show file tree
Hide file tree
Showing 19 changed files with 1,125 additions and 776 deletions.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,11 @@ import { createRoute } from 'react-corsair';

const userRoute = createRoute({
pathname: '/user',
content: () => import('./UserPage')
lazyComponent: () => import('./UserPage')
});
```

Render [`Router`](https://smikhalevski.github.io/react-corsair/classes/RouterProvider.html) component to set up
Render [`Router`](https://smikhalevski.github.io/react-corsair/classes/Router.html) component to set up
the router:

```tsx
Expand Down
677 changes: 400 additions & 277 deletions package-lock.json

Large diffs are not rendered by default.

14 changes: 7 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,15 +44,15 @@
"@types/react": "^18.3.3",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"prettier": "^3.3.2",
"rimraf": "^5.0.7",
"rollup": "^4.18.0",
"ts-jest": "^29.1.5",
"prettier": "^3.3.3",
"rimraf": "^6.0.1",
"rollup": "^4.18.1",
"ts-jest": "^29.2.2",
"tslib": "^2.6.3",
"typedoc": "^0.25.13",
"typedoc": "^0.26.4",
"typedoc-custom-css": "github:smikhalevski/typedoc-custom-css#master",
"typedoc-plugin-mdn-links": "^3.1.27",
"typescript": "^5.4.5"
"typedoc-plugin-mdn-links": "^3.2.4",
"typescript": "^5.5.3"
},
"peerDependencies": {
"react": ">=16.8.0"
Expand Down
2 changes: 1 addition & 1 deletion rollup.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,5 @@ module.exports = {
{ format: 'es', entryFileNames: '[name].mjs', dir: './lib', preserveModules: true },
],
plugins: [typescript({ tsconfig: './tsconfig.build.json' })],
external: ['react', 'parallel-universe', 'urlpattern-polyfill'],
external: ['react', 'parallel-universe'],
};
11 changes: 4 additions & 7 deletions src/main/Navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export class Navigation {
}

/**
* Prefetch the content and data of a route and its ancestors matched by a location.
* Prefetch a content of a route matched by a location and content of its ancestors.
*
* @param to A location or route.
* @returns `true` if the route was prefetched, or `false` if there's no route in the router that matches the provided
Expand All @@ -50,15 +50,12 @@ export class Navigation {
prefetch(to: To): boolean {
const location = toLocation(to);
const { routes, context } = this._router.props;
const routeMatch = matchRoutes(location.pathname, location.searchParams, routes)?.pop();

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

if (routeMatches === null) {
if (routeMatch === undefined) {
return false;
}
for (const routeMatch of routeMatches) {
routeMatch.route.loader(routeMatch.params, context);
}
routeMatch.route.prefetch(routeMatch.params, context);
return true;
}
}
20 changes: 6 additions & 14 deletions src/main/Outlet.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,12 @@
import React, { ReactNode, useContext } from 'react';
import { RouteSlotContent } from './RouteSlotContent';
import { Slot } from './Slot';

export const RouteSlotContentContext = React.createContext<RouteSlotContent | undefined>(undefined);
import { Slot, SlotValueContext } from './Slot';

/**
* Props of an {@link Outlet}.
*/
export interface OutletProps {
/**
* A content that is rendered if there's nothing render.
* A content that is rendered if there's no route to render.
*/
children?: ReactNode;
}
Expand All @@ -18,14 +15,9 @@ export interface OutletProps {
* Renders a route provided by an enclosing {@link Router}.
*/
export function Outlet(props: OutletProps): ReactNode {
const content = useContext(RouteSlotContentContext);
const value = useContext(SlotValueContext);

if (content === undefined) {
return props.children;
}
return (
<RouteSlotContentContext.Provider value={content.childContent}>
<Slot content={content} />
</RouteSlotContentContext.Provider>
);
return value === undefined ? props.children : <Slot value={value} />;
}

Outlet.displayName = 'Outlet';
6 changes: 3 additions & 3 deletions src/main/PathnameAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ const STAGE_OPTIONAL = 4;
/**
* A result of a pathname pattern parsing.
*/
interface Template {
interface PathnameTemplate {
/**
* A non-empty array of segments and param names extracted from a pathname pattern.
*/
Expand All @@ -168,7 +168,7 @@ interface Template {
/**
* Parses pathname pattern as a template.
*/
export function parsePathname(pathname: string): Template {
export function parsePathname(pathname: string): PathnameTemplate {
const segments = [];
const flags = [];

Expand Down Expand Up @@ -261,7 +261,7 @@ export function parsePathname(pathname: string): Template {
/**
* Creates a {@link !RegExp} that matches a pathname template.
*/
export function createPathnameRegExp(template: Template, isCaseSensitive = false): RegExp {
export function createPathnameRegExp(template: PathnameTemplate, isCaseSensitive = false): RegExp {
const { segments, flags } = template;

let pattern = '^';
Expand Down
145 changes: 70 additions & 75 deletions src/main/Route.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,17 @@
import { ComponentType } from 'react';
import { Outlet } from './Outlet';
import { PathnameAdapter } from './PathnameAdapter';
import { Dict, LoadingAppearance, Location, LocationOptions, ParamsAdapter, RouteOptions } from './types';
import { isPromiseLike } from './utils';
import { Outlet } from './Outlet';

type Squash<T> = { [K in keyof T]: T[K] } & {};

/**
* A content returned by {@link Route.loader}.
*
* @template Data Data loaded by a route.
*/
export interface RouteContent<Data = any> {
/**
* A route {@link RouteOptions.component component} .
*/
component: ComponentType;

/**
* Data loaded by a {@link RouteOptions.loader}.
*/
data: Data;
}

/**
* A route that can be rendered by a router.
*
* @template Parent A parent route or `null` if there is no parent.
* @template Params Route params.
* @template Data Data loaded by a route.
* @template Context A context provided by a {@link Router} for a {@link RouteOptions.loader}.
* @template Context A context required by a data loader.
*/
export class Route<
Parent extends Route<any, any, Context> | null = any,
Expand Down Expand Up @@ -74,7 +56,7 @@ export class Route<
errorComponent: ComponentType | undefined;

/**
* A component that is rendered when a {@link loader} is pending.
* A component that is rendered when a component or data are being loaded.
*/
loadingComponent: ComponentType | undefined;

Expand All @@ -84,17 +66,22 @@ export class Route<
notFoundComponent: ComponentType | undefined;

/**
* What to render when a {@link loader} is pending.
* What to render when a component or data are being loaded.
*/
loadingAppearance: LoadingAppearance;

/**
* Loads a component and data that are rendered in an {@link Outlet}.
* Loads data required to render a route.
*
* @param params Route params.
* @param context A context provided by a {@link Router} for a {@link RouteOptions.loader}.
* @param params Route params extracted from a location.
* @param context A {@link RouterProps.context} provided to a {@link Router}.
*/
loader: (params: Params, context: Context) => Promise<RouteContent<Data>> | RouteContent<Data>;
loader: ((params: Params, context: Context) => PromiseLike<Data> | Data) | undefined;

/**
* Loads and caches a route component.
*/
getComponent: () => Promise<ComponentType> | ComponentType;

/**
* Creates a new instance of a {@link Route}.
Expand All @@ -107,16 +94,50 @@ export class Route<
* @template Context A context provided by a {@link Router} for a {@link RouteOptions.loader}.
*/
constructor(parent: Parent, options: RouteOptions<Params, Data, Context> = {}) {
const { pathname = '/', paramsAdapter, loadingAppearance = 'auto' } = options;
const { lazyComponent, paramsAdapter } = options;

this.parent = parent;
this.pathnameAdapter = new PathnameAdapter(pathname, options.isCaseSensitive);
this.pathnameAdapter = new PathnameAdapter(options.pathname || '/', options.isCaseSensitive);
this.paramsAdapter = typeof paramsAdapter === 'function' ? { parse: paramsAdapter } : paramsAdapter;
this.errorComponent = options.errorComponent;
this.loadingComponent = options.loadingComponent;
this.notFoundComponent = options.notFoundComponent;
this.loadingAppearance = loadingAppearance;
this.loader = createLoader(options);
this.loadingAppearance = options.loadingAppearance || 'auto';
this.loader = options.loader;

let component: Promise<ComponentType> | ComponentType | undefined = options.component;

if (component !== undefined && lazyComponent !== undefined) {
throw new Error('Route must have either a component or a lazyComponent');
}

if (component === undefined && lazyComponent === undefined) {
component = Outlet;
}

this.getComponent = () => {
if (component !== undefined) {
return component;
}

component = Promise.resolve(lazyComponent!()).then(
module => {
component = module.default;

if (typeof component === 'function') {
return component;
}
component = undefined;
throw new TypeError('Module must default-export a component');
},
error => {
component = undefined;
throw error;
}
);

return component;
};
}

/**
Expand Down Expand Up @@ -184,53 +205,27 @@ export class Route<
* @param params Route params.
* @param context A context provided to a {@link RouteOptions.loader}.
*/
prefetch(params: this['_params'], context: Context): void {
for (let route: Route | null = this; route !== null; route = route.parent) {
route.loader(params, context);
}
}
}
prefetch(params: this['_params'], context: 0 extends 1 & Context ? void : never): void;

/**
* Creates a function that loads the component and its data. The component is loaded only once, if an error occurs
* during loading, then component is loaded the next time the loader is called.
*/
function createLoader<Params, Data, Context>(options: RouteOptions<Params, Data, Context>): Route['loader'] {
const { lazyComponent, loader } = options;

let component: PromiseLike<ComponentType> | ComponentType | undefined = options.component;

if (component !== undefined && lazyComponent !== undefined) {
throw new Error('Route must have either component or lazyComponent');
}

if (component === undefined && lazyComponent === undefined) {
component = Outlet;
}

return (params, context) => {
component ||= lazyComponent!().then(
module => {
component = module.default;
/**
* Prefetches a component and data of this route and its ancestors.
*
* @param params Route params.
* @param context A context provided to a {@link RouteOptions.loader}.
*/
prefetch(params: this['_params'], context: Context): void;

if (typeof component === 'function') {
return component;
}
component = undefined;
throw new TypeError('Module must default-export a component');
},
error => {
component = undefined;
throw error;
prefetch(params: this['_params'], context: unknown): void {
for (let route: Route | null = this; route !== null; route = route.parent) {
try {
route.getComponent();
route.loader?.(params, context);
} catch (error) {
setTimeout(() => {
// Force uncaught exception
throw error;
}, 0);
}
);

const data = loader?.(params, context);

if (isPromiseLike(component) || isPromiseLike(data)) {
return Promise.all([component, data]).then(([component, data]) => ({ component, data }));
}

return { component, data };
};
}
}
Loading

0 comments on commit bbdc4a2

Please sign in to comment.