developit / htm

Hyperscript Tagged Markup: JSX alternative using standard tagged templates, with compiler support.

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

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.

Video:
bug-movie

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:

Screen Shot 2020-01-27 at 1 34 59 PM

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);

https://codesandbox.io/s/htm-preact-hooks-router-umd-c07qi

That worked.

I'll close this issue as we've determined the source of the bug and alternatives have been documented. Thanks again @developit !