wmr
wmr copied to clipboard
hydration of pre-rendered lazy route / component triggers exception, corrupts component tree
Describe the bug
Exception TypeError: e.__k is null is raised when attempting to hydrate a pre-rendered lazy route / dynamically-imported component. The error doesn't occur when hydrating a non-lazy route first, then navigating to a lazy one.
To Reproduce
npm init wmr lazy-hydrate-errorcd lazy-hydrate-error- open
public/index.js, addonErrorprop e.g. :<ErrorBoundary onError={(e) => { console.log('ErrorBoundary onError: ', e); }}> npm run build && npm run serve- load
http://192.168.1.127:8080/aboutin web browser + open inspector - CTRL/CMD+R / F5 to hard-refresh the page
- observe
TypeError: e.__k is null=> somewhere downstream of thesetState()call chain ... seems to beupdate(1)in thelazywrapper component: https://github.com/preactjs/wmr/blob/928c7a07cae98d3fb00bdaa338bff05453360462/packages/preact-iso/lazy.js#L11
Here is another method to quickly reproduce the bug, from your local copy of the WMR repository:
- in the
mainbranch, runyarn demo serve - open
http://localhost:8080/lazy-and-late - wait 2-3 seconds, then navigate to any non-lazy route (i.e. any other route in the navigation header)
- => OKAY :)
- now hard-refresh
http://localhost:8080/lazy-and-latewithCMD+Rto start over again - DO NOT WAIT 2-3 seconds, instead navigate to any non-lazy route (i.e. any other route in the navigation header) relatively quickly (in my tests, the bug occurs even if I take my time to load another route ... let's say 1s)
- => BREAK :(
Expected behavior
No error should occur.
Desktop (please complete the following information):
- OS: all
- Browser: all
- WMR Version: all
Additional context
This behaviour is problematic when needing to leverage onError to handle error state, see for example this related issue: https://github.com/preactjs/wmr/issues/423
Hello, I have added a screenshot that shows the 100% reproducible exception somewhere downstream of a setState() call chain.
Using the web inspector / debugger, I have narrowed it down to the update(1) call in the lazy wrapper component: https://github.com/preactjs/wmr/blob/928c7a07cae98d3fb00bdaa338bff05453360462/packages/preact-iso/lazy.js#L11
If I comment out this update(1) section:
https://github.com/preactjs/wmr/blob/928c7a07cae98d3fb00bdaa338bff05453360462/packages/preact-iso/lazy.js#L11
...then the exception occurs at the update(0) call in the Router's commit function:
https://github.com/preactjs/wmr/blob/928c7a07cae98d3fb00bdaa338bff05453360462/packages/preact-iso/router.js#L109-L114
What is your method to debug into Preact's internals? (sourcemaps? if so, how to enable them in WMR's prerender build mode?)
If I understand correctly, some code makes use of reserved minified properties (**), so aliasing preact and preact/hooks to the ESM code would break things. I used this alias config in my package.json to debug into WMR's code:
"alias": {
"preact-iso/router": "/PATH/TO/wmr/packages/preact-iso/router",
"preact-iso/hydrate": "/PATH/TO/wmr/packages/preact-iso/hydrate",
"preact-iso/lazy": "/PATH/TO/wmr/packages/preact-iso/lazy",
"__preact__": "/PATH/TO/preact/src",
"__preact/hooks__": "/PATH/TO/preact/hooks/src"
},
(**) For example __d for _dirty:
https://github.com/preactjs/wmr/blob/24417f60def511bfe1d0acbc28f0ab15d19e0925/packages/preact-iso/lazy.js#L22
https://github.com/preactjs/preact/blob/7556b61bd0d9f72bbd906c3abaebf2b1c9aeacbf/mangle.json#L40
"$_dirty": "__d",
Here is another data point to help troubleshoot this problem:
The exception occurs as soon as the lazy / dynamically-imported component is loaded. By adding an artificial loading delay, and by navigating to a different router path during the load, it is possible to reliably reproduce a broken VNode/DOM tree (i.e. the lazy component is displayed at the same time as the new route). Once again, this only occurs in prerender / production mode (npm run build && npm run serve), not in development (npm start)
const AboutLate = lazy(() => import('./pages/about/index.js'));
==>
const AboutLate = lazy(
() =>
new Promise((resolve) => {
setTimeout(() => {
resolve(import('./pages/about/index.js'));
}, 2000);
}),
);
In order to prevent the fatal error described in the message above (which really is a deal breaker for using async routes / lazy components at the moment in WMR), I am using the following workaround which prevents user attempts to trigger other routes while an async route is loading (i.e. lazy component not yet tree-mounted):
EDIT: I use a CSS cursor "not allowed" on router links that do not respond to clicks whilst async route is loading, and I use a CSS cursor "wait" on the link that triggered the lazy component.
useEffect(() => {
const clickHandler = (ev) => {
if (!ev.target) {
return;
}
const linkEl = ev.target.closest('a[href]');
if (!linkEl || linkEl.origin !== window.location.origin) {
return;
}
if (isLoading) {
ev.stopPropagation();
ev.preventDefault();
}
// this is just a handy feature to test full server reload (not necessary for this workaround)
if (ev.altKey) {
ev.stopPropagation(); // prevents preact-iso router (see comment above)
ev.preventDefault(); // prevents default linking / popup menu behaviour
window.location.href = linkEl.href;
}
};
document.addEventListener('click', clickHandler, {
capture: true,
});
return () => {
document.removeEventListener('click', clickHandler, {
capture: true,
});
};
}, [isLoading]);
As the bug only occurs with pre-rendered static SSR (npm run build --prerender), it is possible to only apply this workaround in that mode, and not in development mode (i.e. npm run start). However the discrepancy in behaviour would be confusing, so I apply the workaround everywhere.
As you can see, this technique relies on a boolean variable isLoading, which reflects the router state. More on that in the message below.
Here's how isLoading reflects the router state:
const ctx = {
routerLoading: (isLoading: boolean) => {
//
},
};
const ContextRouterLoading = createContext(ctx);
ContextRouterLoading.displayName = 'Router Loading Context';
export const App = () => {
const [isLoading, setLoading] = useState(false);
const ctx = {
routerLoading: (isLoading) => {
// https://github.com/preactjs/wmr/issues/425
if (!!document.querySelector('script[type=isodata]') // pre-rendered
&& !window.__LAZY_CHECK) {
window.__LAZY_CHECK = true;
if (isLoading) {
return;
}
}
setLoading(isLoading);
},
};
useEffect(() => {
const clickHandler = (ev) => {
if (!ev.target) {
return;
}
const linkEl = ev.target.closest('a[href]');
if (!linkEl || linkEl.origin !== window.location.origin) {
return;
}
if (isLoading) {
ev.stopPropagation();
ev.preventDefault();
return;
}
if (ev.altKey) {
ev.stopPropagation(); // prevents preact-iso router (see comment above)
ev.preventDefault(); // prevents default linking / popup menu behaviour
window.location.href = linkEl.href;
return;
}
};
document.addEventListener('click', clickHandler, {
capture: true,
});
return () => {
document.removeEventListener('click', clickHandler, {
capture: true,
});
};
}, [isLoading]);
return (
<ContextRouterLoading.Provider value={ctx}>
<LocationProvider>
<div>
<Header loading={isLoading} />
<ErrorBoundary
onError={(err) => {
console.log('ErrorBoundary onError: ', err);
}}
>
<Router
onLoadStart={
(url) => {
ctx.routerLoading(true);
}
}
onLoadEnd={
(url) => {
ctx.routerLoading(false);
}
}
>
<RouteWrapper path="/">
<Home />
</RouteWrapper>
<RouteWrapper path="/about-late">
<AboutLate />
</RouteWrapper>
<RouteWrapper default>
<NotFound />
</RouteWrapper>
</Router>
</ErrorBoundary>
</div>
</LocationProvider>
</ContextRouterLoading.Provider>
);
};
Finally, note that the <RouteWrapper> children of <Router> are necessary in my use-case to work around the problem of "error boundary", as reported separately in this issue:
https://github.com/preactjs/wmr/issues/423#issuecomment-797751033
function RouteWrapper(props) {
const [error, setError] = useState(undefined);
this.componentDidCatch = (err) => {
if (!err.then) {
setError(err);
}
};
if (error) {
return (
<>
<p>ERROR!</p>
<p>{error.message}</p>
<button
onClick={() => {
setError(undefined);
}}
>
Try again
</button>
</>
);
}
return props.children;
}
I hope this helps :)
Ah, another behaviour I've noticed is that the exception occurs shortly after hydration, but not instantaneously. There is a short time window during which the user can click on a routed link, resulting in corrupting the component tree. Thankfully that was easy to solve as I already have code in place to check hydration status. I just added a 500ms timeout to provide enough time for the exception to occur, beyond which point the other workarounds detailed above are effective. Phew! :)
I have been running tests based on the stream of tweaks / fixes in preact-iso/router, notably: https://github.com/preactjs/wmr/pull/504 and https://github.com/preactjs/wmr/pull/525 and https://github.com/preactjs/wmr/pull/364 and https://github.com/preactjs/wmr/pull/358
Unfortunately, I am still able to reproduce this bug consistently. Thankfully I am successfully using several workarounds to prevent the bug's occurrence, but the resulting code is quite convoluted. Just to be clear: the TypeError: e.__k is null error cannot just be ignored (it is by default swallowed by the ErrorBoundary), as it seems to be related to some underlying state corruption, as described in the comment thread above.
Here is another method to quickly reproduce the bug, from your local copy of the WMR repository:
- in the
mainbranch, runyarn demo serve - open
http://localhost:8080/lazy-and-late - wait 2-3 seconds, then navigate to any non-lazy route (i.e. any other route in the navigation header)
- => OKAY :)
- now hard-refresh
http://localhost:8080/lazy-and-latewithCMD+Rto start over again - DO NOT WAIT 2-3 seconds, instead navigate to any non-lazy route (i.e. any other route in the navigation header) relatively quickly (in my tests, the bug occurs even if I take my time to load another route ... let's say 1s)
- => BREAK :(
This should have been fixed by #525.