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

Module resoultion in unpkg doesn't work. (Bug in unpkg) #2564

Closed
Pyrolistical opened this issue May 29, 2020 · 6 comments
Closed

Module resoultion in unpkg doesn't work. (Bug in unpkg) #2564

Pyrolistical opened this issue May 29, 2020 · 6 comments

Comments

@Pyrolistical
Copy link

Pyrolistical commented May 29, 2020

Goal

I want to be able to use build websites with preact with modern ES Modules as a single index.html page, ie. no bundler.

Problem

Right now the "browser" build of preact/hooks can be seen here: https://unpkg.com/preact@10.4.4/hooks/dist/hooks.module.js

The problem is, its not browser compatible due to import{options as n}from"preact";

Current solutions

Use unpkg.com with ?module

First of all its a bundler, second of all it load its own un-versioned preact which wouldn't be the same preact I already loaded?

Use npm.reversehttp.com

Again, another bundler and it also always loads the latest version making it hard to build a stable app.

Desired solution: Make preact/hooks pluggable into preact

I like how htm and preact are independent ES Modules that can be wired together, as seen here
https://twitter.com/pyrolistical/status/1266264165317935104

We can even continue to layer on a styled component library

<html>
  <body>
    <script type="module">
      import htm from 'https://unpkg.com/htm@3.0.4/dist/htm.module.js'
      import {h, render} from 'https://unpkg.com/preact@10.4.4/dist/preact.mjs'
      import {styled, setPragma} from 'https://unpkg.com/goober@1.8.0/dist/goober.module.js'
      const html = htm.bind(h)
      setPragma(h)
      const Banner = styled('h1')`
        background-color: red;
      `
      render(html`<${Banner}>thank you @_developit<//>`, document.body)
    </script>
  </body>
</html>

If preact exposed an plugin system of some sort we could do the following:

// fake code
import Preact, {h, render} from 'https://unpkg.com/preact@10.4.4/dist/preact.mjs'
import Hooks, {useState} from 'https://unpkg.com/preact@10.4.4/hooks/dist/hooks.module.js'
Preact.install(Hooks)

Why not just use a cdn bundler? I don't think this scales well into larger projects. When everything is welded together with a bundler, its harder get in there and customize.

And its non-standard! We don't need npm/commonjs anymore now that we have ES Modules.

@JoviDeCroock
Copy link
Member

This seems to be more of an issue with the resolution algorithm of unpkg than a compatability issue. For instance pikacdn does resolve this correctly https://cdn.pika.dev/preact/hooks

@Pyrolistical
Copy link
Author

Pyrolistical commented May 29, 2020

Nice, so cdn.pika.dev implemented it correct, but this still doesn't invalid the fact its compensating for the non-standard preact import in the dist.

Wouldn't it be so much nicer if you could tell people "here's how to install hooks", instead of "oh, use this specific cdn that implemented a particular resolution logic correctly".

@developit
Copy link
Member

Preact's usage is a standard import statement, and also not really at the core of this issue, as I'll get to below.

One point of clarification regarding npm.reversehttp.com - it fully supports npm semver:

// preact and preact/hooks: 10.3.x, and htm/preact: 3.x.x:
import { html, render } from 'https://npm.reversehttp.com/preact@10.3,preact@10.3/hooks,htm@3/preact';

Unpkg: the ?module issue

Regarding unpkg: this is a known bug in unpkg, which there are four open issues for - #198, #129, #123, #121. I've actually spoken to the maintainer about resolving this and tried to fix it myself, but it's difficult. The ?module parameter enables ES Module specifier transforms, but does not resolve imports according to their package.json versions. Instead, it uses the "dependencies" value verbatim, resulting in broken specifiers like this one from preact-render-to-string:

import { options as e, Fragment as t, createElement as r } from "https://unpkg.com/preact@>=10 || ^10.0.0-alpha.0 || ^10.0.0-beta.0?module";

Here's why that's a bug rather than just a performance issue: that specifier URL produces a 302 redirect to the correct URL, which "works". However, the ES Modules specification dictates that module instantiation must be tied to the source URL rather than the final redirected URL. Modules are normally supposed to be singletons, but using redirects like this silently breaks that important assumption. This breaks way more than just hooks - it disables most types of caching too. It means that the following code actually downloads and executes two completely separate copies of a module, despite them having the same final URL:

import 'https://unpkg.com/preact@10?module'
import 'https://unpkg.com/preact@latest?module'

Now, I get that it's super inconvenient that this is the case - heck, I built that whole CDN just to work around the issue for myself. But I also see that this is clearly an unpkg-specific implementation issue, and not something every library should seek to address. That is to say - since this is an infrastructure issue, we shouldn't try to fix it like an architecture issue. Unpkg is Open Source, and while my previous attempt to fix this failed, it's by no means an impossibility.

The real issue

I hope I've made the case for why changing Preact's design isn't appropriate here. However, even if we were to change that API to fix unpkg imports, doing so would not fix the hooks issue by default. This is because, while it looks like the problem here is related to imports, it's actually because the whole concept of "hooks" inherently requires a singleton renderer module. This is true in all frameworks and libraries that implement the "callsite ordering" technique that hooks relies on, and it's one of the more severe limitations of the idea. With imports patched up, the new issue would be that calling Preact.install(hooks) would work the first time, but break everything silently if it were ever to be called again.

In the near term

For what it's worth, it is actually possible to use hooks + htm + etc via unpkg today, without bundling or workarounds. All you have to do is make sure you're using the already-resolved URLs for each module, so that no redirects are ever used:

// you must use @latest when importing preact in order to work around unpkg resolution:
import { h, render } from 'https://unpkg.com/preact@latest?module'
import { useState } from 'https://unpkg.com/preact@latest/hooks/dist/hooks.module.js?module'
// nothing imports these modules so you can version them if you like:
import { html } from 'https://unpkg.com/htm@3.0.4/preact/index.module.js?module'
import { styled, setPragma } from 'https://unpkg.com/goober@1.8.0/dist/goober.module.js'
setPragma(h);
const Banner = styled('h1')`
  background-color: red;
`
render(html`<${Banner}>thank you @_developit<//>`, document.body)

(here's the above on jsfiddle without bundling)

@Pyrolistical
Copy link
Author

Yes, the real issue is "how does preact/hooks get the correct preact instance so it can mess with the options"

The solutions with unpkg ?module or versioned npm.reversehttp.com or cdn.pika.dev would all work for now. But its all to fix the issue of the non-standard import{options as n}from"preact";

For ES Modules to work in the browser as is, the imports must an absolute url or its a relative one. Just from 'preact' is non-standard and requires cdn magic to convert it to an absolute url.

Neither the Preact.install(Hooks) or relative import are nice and each have their problems. But I'm seeing is if we could find a solution that makes it so the dist files can just be hosted anywhere because they are truly ESM compatible as is. This way we can avoid the requirement of "we need a cdn that is smart enough to rewrite non-standard imports"

@marvinhagemeister
Copy link
Member

@Pyrolistical For ES Modules to work as is the library needs knowledge of the path it's hosted at. Let's say we host our app at the document root of a webserver (nginx, apache, etc).

/webroot
  /js
    preact.module.js
    hooks.module.js
  index.html

Relative URLs are immediately out of the picture as they are not based on the location of the js file, but the page that was served to the user. Let's say the user visited: https://example.com/blog/some-post. A relative import like import preact from './js/preact.module.js will resolve to https://example.com/blog/js/preact.module.js, which is wrong. There is the tag in HTML which allows you to change that, but it will affect any relative URL on the page, not just JS imports.

So yeah, doesn't work.

Next let's look at absolute URLs:

For import * as hooks from "/js/hooks.module.js"

  • Webroot: https://example.com/
  • Actual https://example.com/js/hooks.module.js - works!
  • Expected: https://example.com/js/hooks.module.js.

Now what happens if the site is served from a sub-directory like https://example.com/my-app? If we keep importing from /js/hooks.module.js it will resolve wrongly.

For import * as hooks from "/js/hooks.module.js"

  • Webroot: https://example.com/my-app
  • Actual https://example.com/js/hooks.module.js - breaks page (likely a 404)
  • Expected: https://example.com/my-app/js/hooks.module.js.

Doesn't work either. So what can we do to resolve that?

Current solutions

  1. Hardcode an absolute path

We could hardcode something like /js/preact.module.js in our import paths and it would certainly work for a portion of our users that have this exact file structure on their web servers. For everyone else this would break.

  1. Hardcode a CDN URL

To work around brittle import paths we could ship Preact with hardcoded import statements, like import preact from "https://cdn.example.com/preact@10.4.4/preact.module.js.

This works well for some users, but it's not possible for everyone to make a site depend on a third-party server. So that solution is a no go.

  1. Use a CDN that rewrites import paths

Like @developit outlined above a great solution for the time being is to rely on CDNs that can rewrite import paths like https://cdn.pika.dev, https://npm.reversehttp.com or others. Given your last comments it seems like this is not an option for you.

At this point there aren't really other alternative solutions available, because there is a missing piece in ES Modules that hasn't been fleshed out yet.

Future (hopefully)

In essence we need something that makes import statements independent of the location the specified file was served from. Either something like on the server where we can say "resolve this import relative to the current file" or something that allows us to specify where import A should resolve to. The latter is usually referred to as "import maps", but it hasn't gotten much traction yet.

In either way it's not something we can solve on Preact's end, it's a gap in the spec and it needs to be solved there to really make it work. Hopefully this will be tackled in the near future, as native ES Modules are finally gaining traction in real world usage.

Until then bundlers or tools that can rewrite import paths are the only solution that works reliably for all web servers.

Closing as this is an issue with the import spec and not with Preact.

@Pyrolistical
Copy link
Author

Pyrolistical commented May 30, 2020

When I'm talking about relative path imports, I meant only for CDN, not self hosting.

And I also meant only for the dependency like hooks. The top level import for preact would still be an absolute import.

This way we would have

// https://somecdn.com/preact@10.4.4/preact.js
...
export {options}
// https://somecdn.com/preact@10.4.4/hooks.js
import {options} from './preact'
...
export {useState}
// https://somewebsite.com/bundle.js
import {render} from 'https://somecdn.com/preact@10.4.4/preact.js'
import {useState} from 'https://somecdn.com/preact@10.4.4/hooks.js'
...

This solves all the problems. No hardcoded third-party CDN url. Does not require CDNs that know how to rewrite. No npm assumption. Standard ES Module.

The only tricky part of this is if you have a peer dep that is outside of the preact project, they wouldn't be able to get to the same preact instance unless they happen to use the same cdn. Now one could also argue this is a good thing as no third party module can now mess with preact unless the application passed the instance of preact to it.

This would break stuff like preact-router which I'm looking at right now.

@marvinhagemeister marvinhagemeister changed the title Not possible to use preact/hooks without a bundler Module resoultion in unpkg doesn't work. (Bug in unpkg) Mar 22, 2021
@marvinhagemeister marvinhagemeister pinned this issue Mar 22, 2021
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

No branches or pull requests

4 participants