Giter VIP home page Giter VIP logo

atomic-router's Introduction

Atomic Router

Simple routing implementation that provides abstraction layer instead of inline URL's and does not break your architecture

  • Type-safe
  • No inline URL's
  • Atomic routes
  • Does not break architecture
  • Framework-agnostic
  • Isomorphic (pass your own history instance and it works everywhere)

โ—๏ธ Attention: At the moment atomic-router team collecting issues and feature requests to improve current design. Upgrade atomic-router version with caution. We are going to write migration guide when/if the release will contain breaking changes. Thank you for reporting issues ๐Ÿงก

Get view-library bindings

Installation

$ npm install effector atomic-router

Initialization

Create your routes wherever you want:

// pages/home
import { createRoute } from 'atomic-router';
export const homeRoute = createRoute();

// pages/posts
import { createRoute } from 'atomic-router';
export const postsRoute = createRoute<{ postId: string }>();

And then create a router

// app/routing
import { createHistoryRouter } from 'atomic-router';
import { createBrowserHistory, createMemoryHistory } from 'history';
import { homeRoute } from '@/pages/home';
import { postsRoute } from '@/pages/posts';

const routes = [
  { path: '/', route: homeRoute },
  { path: '/posts', route: postsRoute },
];

const router = createHistoryRouter({
  routes: routes,
});

// Attach history
const history = isSsr ? createMemoryHistory() : createBrowserHistory();
router.setHistory(history);

Why atomic routes?

There are 3 purposes for using atomic routes:

  • To abstract the application from hard-coded paths
  • To provide you a declarative API for a comfortable work
  • To avoid extra responsibility in app features

Examples

Fetch post on page open
  1. In your model, create effect and store which you'd like to trigger:
export const getPostFx = createEffect<{ postId: string }, Post>(
  ({ postId }) => {
    return api.get(`/posts/${postId}`);
  }
);

export const $post = restore(getPostFx.doneData, null);
  1. And just trigger it when postPage.$params change:
//route.ts
import { createRoute } from 'atomic-router';
import { getPostFx } from './model';

const postPage = createRoute<{ postId: string }>();

sample({
  source: postPage.$params,
  filter: postPage.$isOpened,
  target: getPostFx,
});
Avoid breaking architecture

Imagine that we have a good architecture, where our code can be presented as a dependency tree.
So, we don't make neither circular imports, nor they go backwards.
For example, we have Card -> PostCard -> PostsList -> PostsPage flow, where PostsList doesn't know about PostsPage, PostCard doesn't know about PostsList etc.

But now we need our PostCard to open PostsPage route.
And usually, we add extra responisbility by letting it know what the route is

const PostCard = ({ id }) => {
  const post = usePost(id);

  return (
    <Card>
      <Card.Title>{post.title}</Card.Title>
      <Card.Description>{post.title}</Card.Description>
      {/* NOOOO! */}
      <Link to={postsPageRoute} params={{ postId: id }}>
        Read More
      </Link>
    </Card>
  );
};

With atomic-router, you can create a "personal" route for this card:

const readMoreRoute = createRoute<{ postId: id }>();

And then you can just give it the same path as your PostsPage has:

const routes = [
  { path: '/posts/:postId', route: readMoreRoute },
  { path: '/posts/:postId', route: postsPageRoute },
];

Both will work perfectly fine as they are completely independent

API Reference

// Params is an object-type describing query params for your route
const route = createRoute<Params>();

// Stores
route.$isOpened; // Store<boolean>
route.$params; // Store<{ [key]: string }>
route.$query; // Store<{ [key]: string }>

// Events (only watch 'em)
route.opened; // Event<{ params: RouteParams, query: RouteQuery }>
route.updated; // Event<{ params: RouteParams, query: RouteQuery }>
route.closed; // Event<{ params: RouteParams, query: RouteQuery }>

// Effects
route.open; // Effect<RouteParams>
route.navigate; // Effect<{ params: RouteParams, query: RouteQuery }>

// Note: Store, Event and Effect is imported from 'effector' package

atomic-router's People

Contributors

01dr avatar alexandrhoroshih avatar beraliv avatar drevoed avatar igorkamyshev avatar js2me avatar kelin2025 avatar sergeysova avatar velialiev avatar zerobias avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar

atomic-router's Issues

Proposal: `chainRoute` helper

Problem

Whenever some route is opened, we often want to do some work before showing actual page

For example,

const postsRoute = createRoute()

sample({
  clock: postsRoute.opened,
  target: getPostsFx
})

const PostsPage = () => {
  const isLoading = useStore(getPostsFx.pending)
  if (isLoading) { /* ... */ }
  /* ... */
}

Yes, you can implement this logic yourself by using basic effector operators.

However, there're two problems.

First-of-all, it's not unified. So each project will implement it differently. Also, we can't build some helpful wrappers around that on the library side

And then, there's lot of boilerplate.
You have to create pending stores, update it, hide page contents, etc.
It might look easy on simple examples. But if you need to do more than a single request, or do some checks like session validation - that's a lot of work

Solution

Introducing chainRoute!

chainRoute is a helper, that creates a virtual clone of passed route and listens to passed events before open

Example:

import { createRoute, chainRoute } from 'atomic-router'

const editBotRoute = createRoute<{ botId: string }>()

const loadedRoute = chainRoute({
  route: editBotRoute,
  beforeOpen: getBotFx.prepend(({ params: { botId } }) => ({ id: botId })),
  openOn: getBotFx.doneData,
  cancelOn: getBotFx.failData
})

What happens here

  1. Create loadedRoute
  2. Listen to route.opened / route.updated
  3. Store current route.$params and route.$query
  4. Trigger beforeOpen
  5. When either cancelOn or route.left is triggered during request, we reset stored params/query
  6. When openOn is triggered, we compare route.$params/$query with stored ones. And, if they are equal, we:
    7.1. Trigger loadedRoute.open with stored params
    7.2. Reset stored params
  7. When route.left is triggered and loadedRoute.$isOpened is true, we also trigger loadedRoute.left

Notes

So many stuff here! Why not accept just route and effect?

Unfortunately, that won't be flexible.

With just effect you cannot solve the following cases:

  • Multiple consequent requests
  • Non-fetch based loading (e.g. through websocket)
  • Custom logic (e.g. session check - skip it if it's already checked, token refresh etc)

in a declarative way.

So we'll give a bit more low-level control over that, so users can implement any logic they want

What are the benefits over sample series?

Like I said at the start, there's a lot of boilerplate required for a manual handling
But moreover, if we have consequent requests or custom logic, we need even more extra code:

For example:

const getUserFx = createEffect(...)
const getUserPostsFx = createEffect(...)

// We need to write this stuff to hide page during load
const $isLoading = every({
  stores: [getUserFx.pending, getUserPostsFx.pending]
  predicate: Boolean
})

With chainRoute you can rely on returned route:

const loadedRoute = chainRoute({
  route: mainRoute,
  beforeOpen: getUserFx,
  openOn: getUserPostsFx.doneData,
  cancelOn: [getUserFx.failData, getUserPostsFx.failData]
})

// And just use loadedRoute.$isOpened to display the contents

Some sugar, please!

Sure.

// Use already existing route instead of creating new one
chainRoute({
  route: mainRoute,
  /* ... */
  chainedRoute: loadedRoute
})
// Duplicate route (just for fun)
const chainedRoute = chainRoute(mainRoute)
// Pass only `beforeEnter` effect 
// to use its `doneData/failData` as `openOn/cancelOn`
const loadedRoute = chainRoute({
  route: mainRoute,
  beforeOpen: getUserFx
})
// Multiple triggers (like clock in `sample`)
const loadedRoute = chainRoute({
  route: mainRoute,
  beforeOpen: [cleanupForm, getUserFx],
  openOn: [
    guard({ clock: getUserFx.doneData, filter: ... })
  ],
  cancelOn: [
    getUserFx.failData, 
    guard({ clock: getUserFx.doneData, filter: ... })
  ]
})

Purpose of this thread

Before I officially publish this solution, I'd like to share it with users, so we could discuss

  • Can it solve all the needed cases
  • Can we do better
  • Do we need extra API
  • or, y' know, think of betters namings etc

So yeah, feel free to write your thoughts on that!

API for subrouting

Sometimes we have some "parent" page with subpages.
E.g. user page where you can see user posts (/my/posts) or info (/my/info)
Would be cool to have something like this:

const router = createHistoryRouter({
  { 
    path: '/my',
    route: myRoute,
    children: [
      { path: '/posts', route: myPostsRoute },
      { path: '/info', route: myInfoRoute }
    ]
  }
})

Which would work almost the same way as

const router = createHistoryRouter({
  { path: '/my/(posts|info)*', route: myRoute },
  { path: '/my/posts', route: myPostsRoute },
  { path: '/my/info', route: myInfoRoute }
})

Middlewares?

In the first draft of this router, routes had had .onEnter method:

// Loads post before route is actually opened
postRoute.onEnter(async ({ params }) => {
  const post = await getPostFx({ postId: params.postId })
  return true
})

// Redirect
adminRoute.onEnter(async ({ query }) => {
  const isAllowed = await checkIsAllowed()
  if (!isAllowed) {
    return { route: notAllowedRoute, query }
  }
})

And you could subscribe to entered/resolved events separately

route.$isEntered // Entered route
route.$isResolved  // Passed all hooks and resolved

forward({
  from: route.resolved,
  to: doSomething
})

There is no hooks in current version, because I considered that an anti-pattern.
It seems pretty comfy and familiar, but results in a lot of imperative code, which is not the way you write logic for effector
But I still think that we need something like this in order to get consistent way to handle data fetching and other stuff on route enters
So, any ideas of a better implementation would be great!

Proposal: `redirect` helper

Idea

Core effector operators (like sample) are good

However, they are abstract. Which means that they don't say themselves what business logic we create. They just "connect things together"

For that, I introduced an utility called redirect

Usage

redirect({
  clock: dataSaved,
  route: successfullRoute,
  params: () => ({ ... }),
  query: () => ({ ... })
})

This thing is equal to:

sample({
  clock: dataSaved,
  fn: () => ({ params: { ... }, query: { ... } }),
  target: successfulRoute.navigate
})

Notes

  • params and query are optional. They also can accept stores or plain objects instead
  • clock is optional as well. If it's not passed, redirect returns Event which will open the route

Move `$query` outside routes?

Tl;dr

For now each route has route.$query. But it's the same for each route.
That might also be confusing with nested routes (see #19 for more details)

Example

Imagine that we have nested routes

/* /posts/:postId */
const postRoute = createRoute<{ postId: string }>()
/* /posts/:postId/comments/:commentId */
const postCommentRoute = postRoute.createRoute<{ commentId: string }>()

Problem is simple:

If we open /posts/1/comments/1 and go to /posts/1/comments/2, we'll update only postCommentsRoute.
Because only postCommentRoute params got changed.

But if we go /posts/1/comments/1 to /posts/1/comments/1?foo=bar, we'll update both.
Because $query exists in both routes.

If we do something like chainRoute({ route: postRoute, effect: getPostFx }), it will be uselessly triggered by this change.

Solution

I think it's better to make $query external:

// @/shared/query
import { createQuery } from 'atomic-router'

export const $query = createQuery()

// @/pages/post-comments
import { $query } from '@/shared/query'

// Fetch something based on query updates
sample({
  source: $query,
  filter: postCommentsRoute.$isOpened,
  target: getCommentsFx
})

// @/app/routing
import { $query } from '@/shared/query'

const router = createHistoryRouter({
  routes: [...],
  query: $query  // Router will sync query updates with this store
})

Bonus

Also, it solves the problem of query-syncing
You can just update $query directly in order to update query params, and subscribe to $query in order to get its updates.
For example, search request based on query params can be implemented very easily:

import { $query } from '@/shared/query'

$query
  .on(minPriceChanged, (query, price) => ({ ...query, minPrice }))

sample({
  clock: $query,
  filter: searchGoodsRoute.$isOpened,
  target: searchGoodsFx
})

URL option for `redirect`?

Kinda edgy idea but:

redirect({
  clock: openExternalRoutePressed,
  params: { postId: $postId },
  route: 'https://example.com/posts/:postId`
})

+Can be useful for external redirects
-Users can use it for internal ones instead of passing routes

Routing with lazy loading components

I have such file structure:

- store.ts
- view.tsx
- page.ts
- app.tsx
// page.ts
import { createRoute } from "atomic-router";
import { lazy } from "react";
   
const Page = lazy(() => import("./view"));
const route = createRoute();
export const somePage = {
  route,
  Page,
};
// app.tsx
import { Suspense } from "react";
import { Route, RouterProvider } from "atomic-router-react";
import {somePage} from "page";

const router = createHistoryRouter({
   routes: {path: "/page", route: somePage.Page}
});

export const App = () => {
  return (
    <Suspense fallback="...">
      <RouterProvider router={router}>        
            <Route route={somePage.route} view={somePage.Page} />        
      </RouterProvider>
    </Suspense>
  );
};
// store.ts
import { forward, createEffect, createStore } from "effector";

const getDataFx = createEffect(() =>
    loadDataFromApi()
);


export const $pageData = appDomain
  .createStore<any>(null)
  .on(getDataFx.doneData, (_, data) => data);

forward({
   from: somePage.route.open,
   to: loadDataFx
});
// view.tsx
import React from "react";
import { useStore } from "effector-react";
import { $pageData} from "./store.ts";

const Page = () => {
    const data = useStore($pageData);
    return <div>{data}</div>;
}

So, route.open event fires before store is loaded. And loadDataFx does not execute. What is the correct way to use the atomic-router in this case?

Wider `RouteInstance<T>` cannot be passed into narrow `RouteInstance<D>`

const route: RouteInstance<{ userId: string; postId: string }>

function chainer(route: RouteInstance<{ userId: string }>): RouteInstance<{ userId: string }>

chainer(route)

Looks like it should work: more wide { userId, postId } should fit { userId }.

This is can also be read like: type { userId: string; postId: string } should met conditions { user: string }, but it's not:

Argument of type 'RouteInstance<{ userId: string; postId: string; }>' is not assignable to parameter of type 'RouteInstance<{ userId: string; }>'.
  The types returned by 'navigate.use.getCurrent()' are incompatible between these types.
    Type '(params: NavigateParams<{ userId: string; postId: string; }>) => Promise<NavigateParams<{ userId: string; postId: string; }>>' is not assignable to type '(params: NavigateParams<{ userId: string; }>) => Promise<NavigateParams<{ userId: string; }>>'.
      Types of parameters 'params' and 'params' are incompatible.
        Type 'NavigateParams<{ userId: string; }>' is not assignable to type 'NavigateParams<{ userId: string; postId: string; }>'.

https://www.typescriptlang.org/play?#code/JYWwDg9gTgLgBAbzgJQgVxgUwJIDsDOMAhrgMaZwC+cAZlBCHAOREwPCkC09GmUTAKAEATTKQA2RKBVIQC8HlgBcKdFjyES5ADxI0+PtmErCUYLgDmAbjiRCRkzDOWqAPiGiJUijTRkYwHJwpAAWROZ8ABSKmCqovBrEZJi6cPqGxnCm5hZuAJRxajjyWil6BlAOWU45bkKh4bhRMXlAA

Prevent transition (+confirmation) from route feature

Would be nice to have in-built way to prevent/confirm user transition from route to another

chainRoute({
    route: someRouteWithFormForExample,
    
    beforeClose: {
    // condition to trigger on try
    confirmWhen: $store, 
    // trigger to react on attempt to leave route
    onLeave: showConfirmation,
    // trigger to confirm transition
    closeOn: confirmLeaveClicked,
  }
})

also in my case for example i have a page which allows to leave it only if user goes to specific routes so maybe it's also worth it to add some fields like

    // if transition to this routes no confirmation needed
    allow: [route1, route2, route3],
    // prevents transition only to those routes, should pass only prevent or only allow
    prevent: [route4, route5]

Find out a simple way to avoid data fetching on client-side with SSR

When using SSR, we can run some code only on server-side just by wrapping it into bundler-specific condition.
For example, with Vite it's pretty simple:

if (import.meta.env.SSR) {
  const fetchPostsFx = createEffect(async ({ lang }) => {
    const { fetch } = await import("cross-fetch");
  
    return fetch(`/api/get-posts?lang=${lang}`)
      .then((r) => r.json())
      .then((r) => r.posts);
  });
  
  $posts.on(fetchPostsFx.doneData, (prevPosts, nextPosts) => nextPosts);
  
  sample({
    clock: homeRoute.opened,
    target: fetchPostsFx,
  });
}

However, we also need to fetch posts when we enter the route from client-side navigation. But not on initial render, because it's already received from SSR

Partial params update of the Route

Use-case:

export const mainRoute = createRoute<{
  categoryId?: string;
  locationId?: string;
}>();


sample({
  clock: selectLocation,
  source: mainRoute.$params,
  fn: (params, id) => ({ ...params, locationId: id }),
  target: mainRoute.open,
});

sample({
  clock: unselectLocation,
  source: mainRoute.$params,
  fn: (params, id) => ({ ...params, locationId: undefined }),
  target: mainRoute.open,
});

Better typings for `.open()` and `.navigate()`

For now we have to pass empty object to params, even if our route does not have any

const homeRoute = createRoute()

homeRoute.open({}) // <- here

Would be better if we could call just homeRoute.open(), but only for routes without params

RFC API changes for chainRoute

chainRoute is a powerful good method, but the naming of parameters is kinda weird. Firstly, the chain usually happens as prepend, not as something that would stay on the right side.

I propose to deprecate chainRoute and use a better word that is already used in the documentation:

virtualRoute({
  parentRoute: Route,
  openOn?: same,
  condition?: Store,
  onParentNavigated?: like beforeOpen
})

or add to chainRoute parentRoute as a parameter instead of the route, increasing understanding of how it works dramatically. Deprecate the route parameter. That is only naming but think that it will help my colleagues to use easily atomic-router.

No babel-preset export in package.json

After updating from 0.8.0 to 0.9.3, the project does not build. I get an error

Error: Package subpath './babel-preset' is not defined by "exports" in /.../node_modules/atomic-router/package.json imported from /.../babel-virtual-resolve-base.js

I think you need to add babel-preset export to package.json

RFC API changes for RouteInstance

I propose to add to Route instance navigated and paramsApplied events.

Like in the example below

type ExtendedRoute<Params extends RouteParams> = RouteInstance<Params> & {
    navigated: Event<{ params: Params; query: RouteQuery }>;
    paramsApplied: Event<Params>;
}

export function createRoute<Params extends RouteParams>(): ExtendedRoute<Params> {
  const route = atomicRouter.createRoute<Params>();
  return {
    ...route,
    navigated: merge([route.opened, route.updated]),
    paramsApplied: sample({
        clock: [route.$isOpened, route.$params],
        source: route.$params,
        filter: route.$isOpened,
    })
  }
}

It would increase comfort because using [opened, updated] is one of the most popular patterns.
Params applied event that should propagate params in the correct way when it's really applied on the page. We could write this by myself but still, it would be comfortable for using people behind it.

`route.$params` triggers whenever values of params actually not changed

There is some code to explain the issue:

https://codesandbox.io/s/practical-kirch-hdcgnc?file=/src/App.js

Whenever user clicks to next comment link, the $params watch is triggered as we would expect it.
But if user clicks current comment which has exact same params, watch will also trigger.

Problem: if there is a link which triggers fetching some data, if this link is pressed with exact same params, there will be unneccessary requests, which i would like to avoid.
Solution: any way to controll $params trigger, e.g. comparing previos params and next params

chainRoute not working

ะŸั€ะธ ะฟะตั€ะตะทะฐะณั€ัƒะทะบะต ัั‚ั€ะฐะฝะธั†ั‹, ัƒ chainRoute ะฝะต ัั€ะฐะฑะฐั‚ั‹ะฒะฐะตั‚ beforeOpen ัะพะฑั‹ั‚ะธะต?
ะŸั€ะธ ัั‚ะพะผ ะตัะปะธ ะฟะตั€ะตั…ะพะดะธั‚ัŒ ะฟะพ ััั‹ะปะบะฐะผ - ั‚ะพ ะฒัะต ั€ะฐะฑะพั‚ะฐะตั‚.

ะ’ะพั‚ ะฟั€ะธะปะพะถะตะฝะธะต ั ะฒะพัะฟั€ะพะธะทะฒะตะดะตะฝะธะตะผ ะฟะพะฒะตะดะตะฝะธั.

ะŸะพัะปะต ั‚ะพะณะพ ะบะฐะบ ะทะฐะฟัƒัั‚ะธั‚ัั, ะฝัƒะถะฝะพ ะฒ ะฐะดั€ะตัะฝะพะน ัั‚ั€ะพะบะต ะดะพะฟะธัะฐั‚ัŒ /#/admin ะธ ะพะฑะฝะพะฒะธั‚ัŒ ัั‚ั€ะฐะฝะธั†ัƒ. ะ’ั‹ ัƒะฒะธะดะธั‚ะต ั‚ะพะปัŒะบะพ Loading, ะฐ ัะฐะผะพะณะพ ัะพะฑั‹ั‚ะธั viewerAuthCheckStarted ะฝะต ะฑัƒะดะตั‚. ะฅะพั‚ั ะตัะปะธ ะฟะตั€ะตะนั‚ะธ ะธะท ะฑะพะบะพะฒะพะณะพ ะผะตะฝัŽ, ั‚ะพ ะฒัะต ะพะบ ะฑัƒะดะตั‚.

ะญั‚ะพ ะผะพะถะตั‚ ะฑั‹ั‚ัŒ ะธะท-ะทะฐ ั‚ะพะณะพ ั‡ั‚ะพ ั ัะฐะผ ั€ะพัƒั‚ ะพะฑัŠัะฒะปััŽ ะดะพ ั‚ะพะณะพ, ะบะฐะบ ะธัะฟะพะปัŒะทัƒัŽ ะฒ chainRoute?
ะกะฐะผ chainRoute ัƒ ะผะตะฝั ะพะฑัŠัะฒะปัะตั‚ัั ะฒ ัะฐะผะพะน ัั‚ั€ะฐะฝะธั†ะต, ะฝะฐ ะบะพั‚ะพั€ะพะน ะฝัƒะถะฝะพ ั‡ั‚ะพ-ั‚ะพ ะทะฐะณั€ัƒะทะธั‚ัŒ. (ั„ัƒะฝะบั†ะธั securedRoute, ะฒ ะบะพั‚ะพั€ะพะน ะธัะฟะพะปัŒะทัƒะตั‚ัั chainRoute ะฝะฐั…ะพะดะธั‚ัั ะฒ ั„ะธั‡ะฐั…)

Reset scroll feature

It would be nice if atomic router would have a feature to reset scroll on history change.

`back`/`forward` navigation

Currently atomic-router does not have programmatic API for back/forward navigation
You can call history.back() from the current history instance, but it can lead to improper imports (e.g. from app/router to features/some-feature, or import history from separated directory and have two sources for similar tasks)

Show error page when fail effect

  1. I want to show page error, if some effect on page failed.
import { PageErrorContainer } from '../PageError/PageErrorContainer';

export const initRouter = (params?: { isTest?: boolean }) => {
    const routes = [
        { path: '/', route: profileRoute },
        { path: '/activities', route: activitiesRoute },
        { path: '/error', route: errorRoute },
    ];

    const router = createHistoryRouter({
        routes,
    });

    router.setHistory(params?.isTest ? createMemoryHistory() : createBrowserHistory());

    const RoutesView = createRoutesView({
        routes: [
            { route: profileRoute, view: PageProfileContainer, layout: getPageLayout() },
            { route: activitiesRoute, view: PageActivitiesContainer, layout: getPageLayout() },
            { route: errorRoute, view: PageError },
        ],
        otherwise() {
            return <PageErrorContainer type={ErrorType.NotFound} />;
        },
    });

    return {
        router,
        RoutesView,
    };
};
sample({t
    source: loginFx.fail,
    fn: () => ({ params: {}, query: {} }),
    target: errorRoute.navigate,
});

But it redirect to path /error, because I can`t define route without path.

How can I show PageError on the same path as profileRoute or activitiesRoute?

Use only one way of handling search string

Currently, there are two ways of handling search string:

Only one should be used to avoid possible errors. They work slightly different. Handling of basic strings is the same, but the rest is different.

You can change this way, then qs dependency will be not needed anymore. (There will be only basic stuff handling, but complex stuff is not handled right now anyway)

- parse(history.location.search.slice(1)) as RouteQuery,
+ Object.fromEntries(new URLSearchParams(history.location.search))

Route with params cannot be assigned into createRouteView.route

Use-case createRouteView:

import { createRouteView } from "atomic-router-react";
import { PageLoader } from "@/shared/ui/pageLoader/PageLoader";
import { EventPage } from "./ui/EventPage";
import { authorizedRoute, currentRoute } from "./model";

export const EventRoute = {
    view: createRouteView({
        route: authorizedRoute,
        view: EventPage,
        otherwise: PageLoader,
    }),
    route: currentRoute,
}

I get error due type:
Type 'RouteInstance<{ paramId: string; }>' is not assignable to type 'RouteInstance | RouteInstance[] | undefined'.

image

Type authorizedRoute:

const authorizedRoute: RouteInstance<{
    eventId: string;
}>

To avoid errors you need to use Type assertion:

export const EventRoute = {
    view: createRouteView({
        route: authorizedRoute as RouteInstance<any>,
        view: EventPage,
        otherwise: PageLoader,
    }),
    route: currentRoute,
};

Two-way binding `URL <-> $params/$query`?

For now $query has only one-way (URL changed -> $query updated) binding
But sometimes we need to sync it back
However, for now updating $query store won't trigger URL update (neither from router nor from routes)

Use cases

Search pages

For example, on some page with dynamic filters/search, we might want to store filters in query

searchRoute.$query
  .on(combine({ q: $q, minPrice: $minPrice, maxPrice: $maxPrice }), (prev, next) => next))

Magic auth links

If you have magic links that authorize you (e.g. "Click here to authorize" email or navigation between 2 subdomains with different frontend), you probably want to remove sensitive data:

router.$query.on(router.initialized, ({ token, ...query }) => query)

Controversy 1

For now you can update query only through someRoute.navigate with current $params and $query
But this will trigger a lot of unnecessary computations + semantically it does not seem correct.
In use case โ„–2, you don't even have "a specific" route, you just need to erase token from it, no matter what route is opened

Controversy 2

Actually I don't like direct updates of $params and $query.
I'm sure it should be controlled through open/navigate methods, and updates obtained through opened/updated/closed events.
Because listening to $params / $query will lead to "is this update came from route.$query changed by user or it's router sent new query to route.$query" problem

Thoughts? ๐Ÿค”

React components

We need Link, Route and RouterView implementation
It's currently implemented locally on my project, but I want to polish it before publishing

CRA+Craco breaks atomic-router with `no handler used in X`

It seems like using atomic-router in cra+craco bundled app breaks the router.

This error is thrown when route.open() / router.push called (just added catch). No path change happenes/atomic-router-react also doesnt render anything in routes and active routes are empty

image

Repro:
https://github.com/ein1x3x/craco-atomic-repro

scripts are
craco:start
cra:start

you can try commenting configuration in CRA config but from what i've tried it doesnt help at all so it's less likely related to plugins and other config in it

IMPORTANT:
dont forget to clear cache (just delete node_modules/.cache) before running start scripts since it seems like it cachaces and doesnt rebuild when you run one script after another

How to replace history when used QuerySync?

There are cases when you need to replace history when using QuerySync.
Is it possible to implement this?

Example.

Back button (controls.back) when clicked, the query parameters will be replaced and we will remain on the same page. It is necessary to unconditionally return to the previous route (where they came from)

`createTask` method [DRAFT]

Instead of making a thousand props for chainRoute (see #28 for example), I'd like to introduce createTask method and move some of props outside chainRoute

Example:

chainRoute({
  route,
  beforeOpen: createTask({
    trigger: authCheckStarted,
    done: [alreadyAuthorized, authorizedSuccessfully],
    fail: [notAuthorized, refreshTokenFailed]
  })
})

createTask returns Effect that calls trigger when it's triggered, calls doneData when done is triggered and failData when fail is triggered
Also some notes:

  • done/fail params can be omit if trigger is Effect (will be filled with trigger.doneData & trigger.failData)
  • Common tasks can be declared in a shared layer and then be copied via createTask({ trigger: task })

If we make it, we can easily introduce beforeClose to chainRoute and use createTask there:

const checkAuthorizedFx = createTask({
  trigger: authCheckStarted,
  done: [alreadyAuthorized, authorizedSuccessfully],
  fail: [notAuthorized, refreshTokenFailed]
})

const confirmLeaveFx = createTask({
  trigger: showConfirmationFx,
  done: [confirmPressed],
  fail: [cancelPressed]
})

chainRoute({
  route,
  beforeOpen: checkAuthorizedFx,
  beforeClose: confirmLeaveFx,
  chainedRoute: authorizedEditRoute
})
  • authorizedEditRoute will be opened only after we ensure that user is authorized
  • authorizedEditRoute will be closed only after it's confirmed

Add possibility to dynamically add routes to a router

Reason

I would like to be able to create routes dynamically. So instead of importing all routes and passing them to the router, I would like to first create a router and export it to be used by routes. I feel it would work better for the remote chunks and for nested routes.

Example

const router = createHistoryRouter();

// shorter way
const postsRoute = router.createRoute("/posts");

// or could be also longer way
const postsRoute = createRoute();
router.addRoute("/posts", postsRoute);

Side note

I think it's currently the main difference between atomic-router and effector-easy-router (don't have enough time to develop it)

Bug: Unnecessary `route.updated` calls

Bug

  • route.opened is called even when it's already opened
  • route.updated is called twice per each open, even if it's not opened

Expected behavior

  • Whenever route is opened, it only triggers route.opened
  • Whenever opened route updates params, it should trigger route.updated

Add way to validate/parse optional params

import { createHistoryRouter, createRoute, createRouterControls } from 'atomic-router';
import { createBrowserHistory } from 'history';


export const controls = createRouterControls();

export const routes = {
  home: createRoute(),
};

export const notFoundRoute = createRoute();

export const routesMap = [{ path: '/', route: routes.home }];

export const router = createHistoryRouter({
  base: '/:lang?',
  routes: routesMap,
  notFoundRoute,
  controls,
});

const history = createBrowserHistory();

router.setHistory(history);

I have a problem
I have supportedLanguages: ['en', 'ru']
I want to match somehow lang parameter to supported languages

Expected bahavior:

/ru/ -> this is a routes.home and lang === 'ru'

/ -> this is also routes.home and lang === undefined

/custom -> this is notFoundRoute and lang === undefined

Actual behavior:

/custom -> give me routes.home, because lang was resolved as 'custom'

and I don't know how to validate it.

Ideas how to solve this:
image

Enter triggers both `route.opened` and `route.updated`

For some reason opened and updated events are fired both when you enter the route

lol.opened.watch(console.log);
lol.updated.watch(console.log);
Result

image

And it gets weirder when you use route.open

const home = createRoute();
const lol = createRoute();

lol.opened.watch((a) => console.log('lol opened', a));
lol.updated.watch((a) => console.log('lol updated', a));

const router = createHistoryRouter({
  routes: [
    { path: '/', route: home },
    { path: '/lol', route: lol },
  ],
});

router.setHistory(createBrowserHistory());

lol.open({});
Result

image

With route.open call delayed:

Result

image

Pending state for `chainRoute`

Add $pending state for chained routes:

const route = createRoute()

const getPostsFx = createEffect(...)

const postsLoadedRoute = chainRoute({
  route,
  beforeOpen: getPostsFx
})

postsLoadedRoute.$pending // Store<boolean>

$pending sets to true when beforeOpen and sets to false after openOn/cancelOn/route.closed

It will also allow us to make preloaders in view-binding libraries.
Example with React:

<Route route={postsLoadedRoute} view={PostsList} preloader={Spinner} />

Programmatically go to the route

Hello

I have 2 routes:

const routes = [
    { path: '/', route: profileRoute },
    { path: '/activities', route: activitiesRoute },
];

export const router = createHistoryRouter({
    routes,
});

router.setHistory(createBrowserHistory());

const RoutesView = createRoutesView({
    routes: [
        { route: profileRoute, view: PageProfileContainer, layout: PageLayout },
        { route: activitiesRoute, view: PageActivitiesContainer, layout: PageLayout },
    ],
    otherwise() {
        return <div>Nothing found 404!</div>;
    },
});

export const AppRoutes: FC = (): ReactElement => {
    return (
        <RouterProvider router={router}>
            <RoutesView />
        </RouterProvider>
    );
};

In component I want to go some route inside useCallback:

import React, { useCallback } from 'react';

import { useUnit } from 'effector-react/scope';
import { $isDarkTheme, profileRoute } from 'src/store';

import { PageActivities } from '.';

export const PageActivitiesContainer: React.FC = () => {
    const isDarkTheme = useUnit($isDarkTheme);

    const clickHandler = useCallback(() => {
        profileRoute.open({});
    }, []);

    return <PageActivities isDarkTheme={isDarkTheme} clickHandler={clickHandler} />;
};

On click it changed browser history, but page doesn`t change.

I didn't found any docs about router.open, router.navigate.

Allow to nest routes with `params` propogation

/org/:orgId/repo/:repoId

const orgRoute = createRoute<{ orgId: string }>();
const repoRoute = orgRoute.createRoute<{ repoRoute: string }>();

1

  1. User on the path /org/100/repo/200
  2. We call repoRoute({ repoId: 123 })
  3. User redirected to /org/100/repo/123

2

  1. User on the path /org/100/repo/200
  2. We call repoRoute({ repoId: 123, orgId: 5 })
  3. User redirected to /org/5/repo/123

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.