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

Simplify Router's async handling and fix stacked transitions #530

Merged
merged 6 commits into from
Apr 16, 2021

Conversation

developit
Copy link
Member

I think I managed to make this decently readable!

@changeset-bot
Copy link

changeset-bot bot commented Apr 6, 2021

🦋 Changeset detected

Latest commit: a7e79c9

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
preact-iso Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions
Copy link
Contributor

github-actions bot commented Apr 6, 2021

Size Change: +569 B (0%)

Total Size: 734 kB

Filename Size Change
examples/demo/dist/about/index.html 673 B +6 B (+1%)
examples/demo/dist/assets/style.********.css 624 B +238 B (+62%) 🆘
examples/demo/dist/chunks/class-fields.********.js 208 B +8 B (+4%)
examples/demo/dist/chunks/compat.********.js 15.3 kB +2 B (0%)
examples/demo/dist/chunks/index.********.js 197 B -2 B (-1%)
examples/demo/dist/chunks/prerender.********.js 2.46 kB +47 B (+2%)
examples/demo/dist/class-fields/index.html 657 B +11 B (+2%)
examples/demo/dist/compat/index.html 1.51 kB +3 B (0%)
examples/demo/dist/env/index.html 732 B +10 B (+1%)
examples/demo/dist/error/index.html 661 B +5 B (+1%)
examples/demo/dist/files/index.html 692 B +4 B (+1%)
examples/demo/dist/index.********.js 7.45 kB +220 B (+3%)
examples/demo/dist/index.html 720 B +6 B (+1%)
examples/demo/dist/json/index.html 671 B +5 B (+1%)
examples/demo/dist/lazy-and-late/index.html 674 B +6 B (+1%)
ℹ️ View Unchanged
Filename Size Change
examples/demo/dist/assets/Calendar.********.css 702 B 0 B
examples/demo/dist/chunks/json.********.js 235 B 0 B
packages/wmr/wmr.cjs 700 kB 0 B

compressed-size-action

@danielweck
Copy link

danielweck commented Apr 6, 2021

Hello, I tested this PR's branch.

As usual, I added the onError() callback on ErrorBoundary so I can visualize console.log('ErrorBoundary onError: ', err);.

  • yarn (makes sure the demo's node_modules is populated with the local unpublished preact-iso package, not the NPM tagged release)
  • yarn demo build (prerender to dist)
  • yarn demo serve (HTTP from dist)
  • navigate to one of the lazy routes ("lazy and late" or "classfields")
  • open web inspector (I always disable the cache to make sure there is no stale data)
  • hard-refresh (CMD + R), and just wait => observe the ErrorBoundary onError() console.log() (Screenshot 1)
  • hard-refresh (CMD + R) again, but this time quickly click on the "home" route header link => observe the corrupted DOM tree, and note that the ErrorBoundary onError() hasn't captured an exception (Screenshot 2)
  • Additional interesting fact: the "home" and "error" routes DO NOT trigger the TypeError: n.__k is null exception! ... AND, "home" and "error" are the only routes with which I am able to consistently reproduce the corrupted DOM tree (Screenshot 3)

Screenshot 1

Screenshot 2021-04-06 at 07 54 12

Screenshot(s) 2

Screenshot 2021-04-06 at 07 54 23

Screenshot 2021-04-06 at 07 57 19

Screenshot(s) 3

Screenshot 2021-04-06 at 08 14 44

Screenshot 2021-04-06 at 08 14 57

@danielweck
Copy link

Additional interesting fact: the "home" and "error" routes DO NOT trigger the TypeError: n.__k is null exception!

@danielweck
Copy link

Another very interesting fact: as I said in the previous comment, the "home" and "error" routes DO NOT trigger the TypeError: n.__k is null exception, AND they are the only routes with which I am able to consistently reproduce the corrupted DOM tree (I added screenshots in my original post).
Correlation ... causation ?? :)

@developit
Copy link
Member Author

@danielweck where are you adding the ErrorBoundary? It seems like that is breaking the suspend model here - ErrorBoundary intercepts suspensions that need to be propagated to Router for this to work.

@danielweck
Copy link

where are you adding the ErrorBoundary?

I'm not adding it anywhere, it's already here :)

<ErrorBoundary>
<Router>
<Home path="/" />
<About path="/about" />
<LazyAndLate path="/lazy-and-late" title={'Lazy and Late'} />
<CompatPage path="/compat" />
<ClassFields path="/class-fields" />
<Files path="/files" />
<Environment path="/env" />
<JSONView path="/json" />
<NotFound default />
</Router>
</ErrorBoundary>

I just add the onError() callback so I can trace errors which would otherwise be silent.

@developit
Copy link
Member Author

Alright, I've pushed a version that works in all these cases.

@danielweck
Copy link

Alright, I've pushed a version that works in all these cases.

Will test asap (11pm here ... and I've got a deadline to meet yesterday so.... ;)

@danielweck
Copy link

Alright, I've pushed a version that works in all these cases.

I tested the official WMR demo following these steps:
#530 (comment)

...and everything seems to work fine :)

(I am now testing my own codebase, after disabling my workarounds ...)

@danielweck
Copy link

I am now testing my own codebase, after disabling my workarounds ...

Alright, so first of all, well done for fixing this complex bug 👍

I still have to work around the following two issues in my particular usage scenario, but perhaps this is concerning in the general case too (I will need to file a separate issue, to document a repro pattern based on WMR's @examples/demo):

  1. if I invoke a setState() hook at an early point in time between hydrate() and when the lazy route has finally loaded (i.e. the deferred component is mounted), then the DOM gets corrupted. Just to be clear: this is with a pre-rendered static SSR build, when landing on a lazy route FIRST, and THEN navigating to another non-lazy route.
  2. same as above, but FIRST I hard-refresh a non-lazy route, and THEN I navigate to a lazy route. The symptom is different, as instead of a corrupted DOM, the transition from non-lazy to lazy component doesn't preserve the non-lazy until the lazy load completes. In other words, the routed component is unmounted immediately, without waiting for the incoming lazy route.

@danielweck
Copy link

danielweck commented Apr 10, 2021

Ah, I noticed this new "bug" (or feature?) following your recent code changes:
As expected, the <Router> onLoadEnd() callback is invoked as soon as a lazy or non-lazy route has successfully loaded ... but also one or more times when navigating to another route (i.e. just before onLoadEnd() for a the newly-loaded non-lazy route, or onLoadStart() for a lazy route).

@danielweck
Copy link

Oh no, loads of errors in dev mode! :(

yarn workspace @examples/demo start

Hard-refresh the lazy route: http://localhost:8080/lazy-and-late
=>
blank page, and in the console:
TypeError: _ is undefined

Screenshot 2021-04-10 at 09 32 22

...Or hard-refresh any non-lazy route: http://localhost:8080/ ... then navigate to http://localhost:8080/lazy-and-late:

Screenshot 2021-04-10 at 09 33 11

@developit
Copy link
Member Author

Oh - sorry @danielweck I should have clarified: the <ErrorBoundary> is no longer required to use Router/lazy().

RE onLoadEnd() - that was intentional, but I agree its a little odd. I want to have a callback for "after routing has completed" that fires both for synchronous and asynchronous routing, because it's essential for implementing things like scroll restoration or loading indicators. In the current API, onLoadStart() means "we are waiting for an async route" and onLoadEnd() means "routing has completed". I've been thinking of better names for these, like onBeforeRoute() and onRoute(). Maybe the simplest solution here though would be to always fire both events, even for synchronous routes.

The first bug you described is one I'm having trouble reproducing - are you setting state "above" the Router, or within a route? The not-yet-loaded route should be in a suspended status that causes any state updates to be dropped. I'm not sure about setting state above the Router - the correct behavior here would be for Preact to process the state update, re-render the Router, and bail out of progressive hydration (making it the same as the next thing:)

The second bug is working-as-designed. When a route is pending, we attempt to render it one or more times in order to check whether it can be un-suspended. In doing so, we partially mutate its DOM tree - during hydration that's the actual DOM tree, whereas during client-side routing that's a work-in-progress disconnected DOM tree. When a different route is requested and we have to abort a pending async route, that DOM tree becomes invalid. Since it may be the current (partially-hydrated) tree, we have to destroy it to prevent the newly requested route from being forced into hydration mode.

I do think that it might be possible to split out the "abort hydration of pending route and render something else" case from the "abort client-side transition due to new client-side transition" case. If we can do that, then the only time you'd get the blank page effect would be when clicking a link to a new route during the period of time when an async route is pending hydration.

@danielweck
Copy link

Thank you for taking the time to explain the underlying logic, much appreciated.

Regarding <ErrorBoundary>, I was just testing the examples/demo ... does <ErrorBoundary> need to be removed in this PR?

@danielweck
Copy link

The first bug you described is one I'm having trouble reproducing - are you setting state "above" the Router, or within a route?

Yes, above the router. That's probably an important distinction indeed! (and a bad design idea on my part?) Anyway, I have since moved to a different solution that doesn't involve local state at that level of the component tree.

@danielweck
Copy link

Are you able to reproduce the broken dev server / prefresh mode? ( TypeError: _ is undefined)
#530 (comment)

Unless I am mistaken: I used yarn workspace @examples/demo start instead of yarn workspace @examples/demo build && yarn workspace @examples/demo serve ... and immediately saw the fatal bug when attempting to display the page (any route).

@developit
Copy link
Member Author

@danielweck I haven't been able to repro yet but I'll give it a go.

ErrorBoundary doesn't need to be removed, just wasn't sure if you were re-rendering via setState in response to <ErrorBoundary onError={...}> (which I haven't tested yet).

@danielweck
Copy link

@danielweck I haven't been able to repro yet but I'll give it a go.

Thank you :)
Unless I am mistaken, this is a blocker for this PR merge.

ErrorBoundary doesn't need to be removed, just wasn't sure if you were re-rendering via setState in response to <ErrorBoundary onError={...}> (which I haven't tested yet).

I was actually re-rendering via setState() near the top of the component tree (above <Router>) in response to <Router> onLoadStart/End(), but yeah, the problem you described applies here too.

@danielweck
Copy link

Oh no, loads of errors in dev mode! :(

yarn workspace @examples/demo start

Hard-refresh the lazy route: http://localhost:8080/lazy-and-late
=>
blank page, and in the console:
TypeError: _ is undefined

...Or hard-refresh any non-lazy route: http://localhost:8080/ ... then navigate to http://localhost:8080/lazy-and-late:

Alright, so the source of the error is undefined oldVNode in:

// See https://github.com/preactjs/preact/blob/88680e91ec0d5fc29d38554a3e122b10824636b6/compat/src/suspense.js#L5
const oldCatchError = options.__e;
options.__e = (err, newVNode, oldVNode) => {
if (err && err.then) {
let v = newVNode;
while ((v = v.__)) {
if (v.__c && v.__c.__c) {
if (newVNode.__e == null) {
newVNode.__e = oldVNode.__e; // ._dom
newVNode.__k = oldVNode.__k; // ._children
}
if (!newVNode.__k) newVNode.__k = [];
return v.__c.__c(err, newVNode);
}
}
}
if (oldCatchError) oldCatchError(err, newVNode, oldVNode);
};

I'll fix this in my local branch to see if it helps.

@danielweck
Copy link

I'll fix this in my local branch to see if it helps.

I have reverted to the main branch of WMR in my experimental app. This PR / branch works great in prerender builds (net benefits compared with the main branch), but the broken development server is a deal breaker. Anyway, am I right to assume that this other PR might be another viable proposal, possibly replacing this PR?

@developit
Copy link
Member Author

@danielweck we don't know yet - as best as I've been able to tell, the linked PR doesn't preserve DOM during routing.

The broken dev server thing is fixable, I'm pretty sure it's just a plugin (possibly prefresh?) forgetting to forward the third argument to options._catchError().

@JoviDeCroock
Copy link
Member

JoviDeCroock commented Apr 13, 2021

Good spot, adding preactjs/prefresh#325

EDIT: published as 1.3.2

@developit
Copy link
Member Author

lol!! you beat me to that :o

@danielweck
Copy link

Awesome quick turnaround, you guys rock! :)
The Prefresh fix means I am now back on this PR/branch 👍

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants