4Catalyzer / found

Extensible route-based routing for React applications

Home Page:https://4catalyzer.github.io/found/

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

`renderArgs.elements` is empty when navigating to the same route

catamphetamine opened this issue · comments

In my app I sometimes have links from a route to the same route, but with different pathname.
Those are forum Thread pages: one thread could link to another thread.
In some previous versions renderArgs.elements seemed to be always present, so my workaround of doing:

renderArgs.elements[renderArgs.elements.length - 1] = 
  React.cloneElement(renderArgs.elements[renderArgs.elements.length - 1], {
    key: renderArgs.location.pathname
  })

seemed to work.
It doesn't seem to work anymore, because when navigating from a thread to another thread renderArgs.elements seems to be undefined.
Could it be the case?
If yes, perhaps could you propose any potential workaround for this case?
My current workaround is creating a wrapper over Thread page component that would set a key on the wrapped Thread page — seems to work.

export default function ThreadPageWrapper() {
  const board = useSelector(({ chan }) => chan.board)
  const thread = useSelector(({ chan }) => chan.thread)
  return <ThreadPage key={`${board.id}/${thread.id}`}/>
}

ThreadPageWrapper.meta = ThreadPage.meta
ThreadPageWrapper.load = ThreadPage.load

I'm not aware of any changes here. Where are you trying to access renderArgs.elements? If elements were somehow not present at the top-level render bit, then I'd expect rendering to completely fail:

} else if (renderReady) {
element = renderReady(renderArgs);
} else {
element = <ElementsRenderer elements={elements} />;
}

That said, if your goal is to do this for your page, could you just do this in the render method on the route itself? Something like:

<Route
  // ...
  render=({ Component, data, match }) => (
    (Component && data) ?
      <Component key={match.location.key} data={data} />  :
      <LoadingIndicator />
  )}
/>

or something along those lines?

Where are you trying to access renderArgs.elements?

In createConnectedRouter.render. See the code:
https://github.com/catamphetamine/react-pages/blob/master/source/router/client.js

That said, if your goal is to do this for your page, could you just do this in the render method on the route itself? Something like:

Well, that's what my code linked above was doing, I assumed: it was supposed to intercept the render() of each and every route and add the key.
Or would you have a better idea of a place where this "hook" could be added (once, not for every route).

Ah, okay – in that place, elements is not necessarily defined. Reference

return function render(renderArgs) {
const { error, elements } = renderArgs;
let element;
if (error) {
element = renderError ? renderError(renderArgs) : null;
} else if (!elements) {
element = renderPending ? renderPending(renderArgs) : undefined;
} else if (renderReady) {
element = renderReady(renderArgs);
} else {
element = <ElementsRenderer elements={elements} />;
}
const hasElement = element !== undefined;
return (
<StaticContainer shouldUpdate={hasElement}>
{hasElement ? element : null}
</StaticContainer>
);
};
– we could be in the "pending" or "error" branch. I'd just wrap your logic above in an if block.

If you were using createRender more-or-less directly to generate the render function to pass into one of the routers, then you could also just put the logic in renderFetched there, but it looks like you have enough custom logic that it'd probably be easier just to use the if (renderArgs.elements) in here.

@taion Actually, your comment helped me understand the issue I was having.
Turned out it wasn't in the router library.
Instead, it's in the way Redux apps are usually engineered.

So, there's a Thread page that fetches some data that is then stored in Redux state under state.thread.data.
When a navigation happens from one thread to another thread, getData() is run which fetches data for the next thread, but the application still renders the previous thread.
If a Thread page uses some state (via useState()) then the navigation transition creates a short period of inconsistency.

For example, in my case, there was a state variable fromIndex that controlled which posts are currently being shown.
If a previous thread had 200 posts with fromIndex being 100 ("showing posts from 100 to 200"), and then the user clicks a link to some other thread having only 50 posts, then that thread data is loaded in Redux state first, while the old thread's page is still being rendered and receiving updates, and there comes a useSelector(state => state.thread.data) update for the new thread data on the old thread page where fromIndex is still set to 100 and where it tries to render posts starting from state.thread.data[100] getting an "index out of bounds" error.

So looks like this issue is not related to the router library in any way: it actually looks like a Redux architecture issue.
The way Redux apps are constructed, only having a single state subtree to hold the data for the object being rendered on screen, and then just rewriting that state subtree when navigating from this object to another object of the same kind, while a better approach would be maintaining a separate state subtree for each individual object id, like Relay does it managing a separate state for each object id, managing caching, clearing cache (RAM), etc.

Just remounting a page component every time location.pathname changes doesn't solve the issue because location.pathname only gets updated after getData() has finished, and Redux state could get updated anywhere between getData() has started and getData() has finished, so there's still a short window of potential inconsistency.


The workaround I've settled on is:

function ThreadPage() {
  ...
}

export default function ThreadPageWrapper() {
  const threadId = useSelector(state => state.data.thread.id)
  return <ThreadPage key={`${threadId}`}/>
}

I could also introduce another workaround: only explicitly declared params would be available. The declaration of params would look like:

PageComponent.params = { 
  threadId: {
    type: 'number',
    getFromState: state => state.thread.id
  }
}

The combined params (from all Components of page's route chain) would get converted to a React key via JSON.stringify(Component.params(state)) and then set on the <PageComponent/> element.
This way, the page component would re-mount every time the data for the new object ID has been loaded during a navigation transition.

That workaround would only work for applications that always specify the full object id in the URL's named parameters and don't put those in the URL query.
Example: /threads/123 instead of /threads?threadId=123.


It could also autogenerate a random key every time a navigation starts, and then use it when rendering the page component, but in that case it would also have an undesired effect that all useState()s of the current page will reset when the navigation starts and will remain reset while the data for the new page is loaded, which would look weird for the user — the page should remain "frozen" after the user has clicked a link, it shouldn't re-render with its state variables reset, which could introduce "blinking" of content: first it would re-render with state reset and then it would render the new page.

A couple of thoughts:

  • Yeah, with app-level state, Redux makes it a little too easy to have a single reducer that has data for multiple entities. Relay does do caching/RAM management, but in practice, unless you're targeting very low-end devices, you don't get much out of that logic. Our production Relay site has all of the GC stuff disabled.
  • What are you trying to display while things are in the process of updating? I guess the way I pictured this working was that getData (or the equivalent) would return a promise, and the route would display some sort of loading indicator and unmount the actual component that renders the fetched data while data were being fetched.

@taion

What are you trying to display while things are in the process of updating? I guess the way I pictured this working was that getData (or the equivalent) would return a promise, and the route would display some sort of loading indicator and unmount the actual component that renders the fetched data while data were being fetched.

The app always displays the "previous" page while loading the "next" one, with a loading indicator on top.
The "prevoius" page isn't unmounted during the transition, and that creates this window of inconsistency where the "previous" page isn't frozen and keeps being updated in real time as the data for the "next" page is being loaded.
I figured it requires some way to "freeze" those real-time updates to the "previous" page component while the "next" page is being loaded.
If I just wrapped some kind of a <Freezer/> around every page, that wouldn't work, because the <Freezer/> would simply return <Page/> element, and the <Page/> element itself would keep getting updates and being re-rendered during the transition.

function Freezer() {
  const snapshot = useRef()
  const freeze = useRef()
  const isLoading = useSelector(state => state.loadingIndicator.isLoadingRoute)
  useEffect(() => {
    if (isLoadingRoute) {
      freeze.current = true
    }
  }, [isLoading])
  if (freeze.current) {
    return snapshot.current
  }
  return snapshot.current = <Page/>
}

function Page() {
  const data = useSelector(state => state.data.data)
  return (
    <section>
      {data}
    </section>
  )
}

It could work if there was a way in React to somehow snapshot the actual tree of DOM elements being rendered, with components like <Page/> reduced to simple DOM elements like <div/>s.
Then it could be worked around like:

function EveryPage() {
  return window.latestPageRenderResult = renderPage()
}

And then that window.latestPageRenderResult snapshot would be used in the <Freezer/> render function.

Maybe I'll have some other ideas...

yeah, makes sense. i think something like the static container approach we have right now is the best possible without suspense, but it doesn't fully work with redux.

suspense makes all of this much easier. some day...