Standalone mjs script loses page state when using preact-router after route change
n8jadams opened this issue · comments
The Problem
State within a <Route>
is lost when using htm/preact/standalone.mjs
and preact-router
after navigating.
Backstory
I was testing out htm/preact/standalone.mjs
with preact-router
and I noticed that the page state gets lost altogether after navigating. There is no error thrown to the console.
Bug Replication
I created a mini-app to replicate this bug. It's a SPA with two routes, /
and /other
, corresponding to a "Toggle" page and an "Other" page. If you load /other
first, and then navigate to "Toggle", the toggler is broken. The same thing occurs if you load Toggle first and navigate to Other and back to "Toggle".
The only case when the "Toggle" state works is on initial page load.
Code (loaded in a <script>
tag with type="module"
):
import {
html,
render,
useReducer
} from '//unpkg.com/htm/preact/standalone.mjs'
import Router from '//unpkg.com/preact-router?module'
function App() {
return html`
<div>
<${Header} url=${this.state.url} />
<${Router} onChange=${e => this.setState(e)}>
<${Toggle} path="/" />
<${Other} path="/other" />
<//>
</div>
`
}
const Header = ({ url }) => html`
<header style="max-width: 400px">
<nav>
<a href="/">Toggle</a>
<a href="/other">Other Page</a>
</nav>
<section>URL:<input readonly value=${url} /></section>
</header>
`
const Toggle = () => {
const [on, toggle] = useReducer(v => !v, false)
return html`
<section>
<h1>Toggle</h1>
<strong>Value: ${on || 'un'}checked</strong>
<br />
<label>
<input type="checkbox" value=${on} onClick=${toggle} />
Check Me
</label>
<br />
<p>
Value toggles after initial pageload, but after nagivation it's
broken...
</p>
</section>
`
}
const Other = () =>
html`
<section>
<h1>Other</h1>
<p>If you navigate to the Toggle Page, it will break its page's state</p>
</section>
`
render(html`<${App} />`, document.body)
What I tried
To ensure that this was indeed an htm/preact/standalone.mjs
problem, I rebuilt this sample app with both transpiled JSX and with htm/preact import
Thanks for this report. Indeed quite an interesting situation. I suspect this might have something to do with 2 instances of Preact getting loaded (one for the standalone bundle and one required by preact-router).
Pinging @marvinhagemeister & @JoviDeCroock, maybe they would have something to say about this. Here's repro link based on @n8jadams's CodeSandbox examples: https://codesandbox.io/s/dropped-state-bug-htm-working-jouqo
Hi @n8jadams! htm/preact/standalone
won't work with any other Preact libraries, because it actually bundles a copy of Preact. In the demo code you showed, preact-router
is importing its own copy of Preact, which gets loaded separately from unpkg. When the router re-renders, it actually switches the backing renderer from htm's copy to using its own copy, which clears all state.
The fix is actually pretty simply thankfully - switch from htm/preact/standalone
to htm/preact
. We only merged preact/hooks
into htm/preact/standalone
, so unfortunately this currently also means importing useReducer
from preact/hooks
. I think we could probably justify re-exporting hooks from htm/preact
.
import { html, render } from "//unpkg.com/htm/preact/index.mjs?module";
import { useReducer } from "//unpkg.com/preact/hooks/dist/hooks.module.js?module";
import Router from "//unpkg.com/preact-router?module";
// everything as before, just now you have one copy of Preact.
Here's a working codesandbox: https://codesandbox.io/s/htm-preact-hooks-router-7z5id
Future: simpler unpkg URLs
I hope that in the future we'll have preact/hooks
and htm/preact
imports that don't require the extra filepath bits at the end (//unpkg.com/preact/hooks?module
). For now they're required because unpkg only reads package.json metadata from the root of an npm module. I have prototyped a fix for this but need to get things finished up before I PR it to them.
Thanks @jviide and @developit! The demo with the changes to only use instantiate Preact once (both in the browser and in the codesandbox) still is broken.
I made another strange observation. I don't know if it's useful. If I load the "Toggle" page first, then click the "Toggle" link, and then the checkbox a couple of times, the state only starts toggling on the second click. Then the state of "checked" is the opposite of what you expect it to be, or out of sync. When the actual checkbox is checked, the value of "checked" is false.
Perhaps this means the problem is within Preact-router?
(Also, I should note that I'm running into this problem in Chrome.)
@n8jadams just to clarify - when you move away from a route, that component is completely unmounted and all state is dropped - that behavior isn't a bug.
You're right about the codesandbox though, and it's actually still getting two copies of Preact. This happens because unpkg transforms import statements to refer back to itself as full URLs when the ?module
querystring parameter is passed. When doing that, it uses peerDependencies
to insert version numbers in the URLs, and since those version numbers are different in preact-router and htm, it ends up causing 2 copies of Preact to be loaded since they're imported using different specifiers. This happens even though the final redirected URLs are the same, which is pretty annoying:
Solutions
Unpkg, but using UMD
Unfortunately, there's only really one way to solve this at the moment, and that's to not use unpkg's ?module
implementation. It's still possible to use unpkg, just with the default UMD bundles. They're a tiny bit larger, but not as much as you'd think (~100b):
<script src="//unpkg.com/preact"></script>
<script src="//unpkg.com/preact/hooks/dist/hooks.umd.js"></script>
<script src="//unpkg.com/htm"></script>
<script src="//unpkg.com/htm/preact/index.umd.js"></script>
<script src="//unpkg.com/preact-router"></script>
<script type="module">
const { html, render } = htmPreact;
const { useReducer } = preactHooks;
const { Router } = preactRouter;
</script>
JSDelivr Combined UMD
You can also try jsdelivr, which is a CDN like unpkg but with a clever "combine" feature that lets you load all five dependencies in a single URL:
<script src="https://cdn.jsdelivr.net/combine/npm/preact/dist/preact.umd.js,npm/preact/hooks/dist/hooks.umd.js,npm/htm,npm/htm/preact/index.umd.js,npm/preact-router">
<script type="module">
const { html, render } = htmPreact;
const { useReducer } = preactHooks;
const { Router } = preactRouter;
</script>
There used to be an amazing web service called packd-es
that actually did this but using ES Modules. You gave it a list of npm dependencies, and it would run them through Rollup to produce a highly optimized "package" module with all of their exports combined. The service was likely too costly to maintain and has been unavailable for a while. It was a barely-modified fork of Rich Harris's packd project.
Snowpack
Another option is to use a tool like Snowpack, which assembles a simple web_modules
directory containing the ES Modules files your app depends on. I went ahead and set up a Glitch demo that you can check out here:
https://glitch.com/edit/#!/htm-preact-hooks-router?path=index.js:4:0
This approach does use npm
, but doesn't use a bundler and still uses native ES Modules. npm is just used to download the packages.
Pre-bundling
It's relatively easy to pre-bundle a set of known dependencies using Rollup or some other tool. I threw this Gist together that does this using Microbundle, and was pleased to see that the combination of HTM+Preact+Hooks+Router is only 6kB over-the-wire. Here's the demo ported over to a single ES Module import for that Gist:
https://codesandbox.io/s/htm-preact-hooks-router-pre-bundled-p9615
(edit 2020-01-29: fixed UMD examples)
Ok. That is a bummer that the unpkg modules aren't playing nicely because of the different preact versions, but I see how that's not really a bug in any of the packages.
Also, thanks for the very thorough response and all of the alternatives and historical knowledge. I appreciate the effort that went into the Snowpack and prebundling demoes and will definitely use either of those approaches moving forward.
In testing out the unpkg or jsdelivr method, it broke due to an error in the preact.html function.
htm.js:1 Uncaught TypeError: t.apply is not a function
at n (htm.js:1)
at e (htm.js:1)
I realize this is unrelated to this bug that I was having.
Thanks again!
Ack! I messed up the assignments - html
is provided by window.htmPreact
.
Here's a codesandbox that actually gets rid of htm/preact
, since that module really just does this:
const { h, render } = preact;
const html = htm.bind(h);
That worked.
I'll close this issue as we've determined the source of the bug and alternatives have been documented. Thanks again @developit !