FredKSchott / esm-hmr

a Hot Module Replacement (HMR) API for your ESM-based dev server.

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Allow Client to Handle Reload

0xcaff opened this issue · comments

The current client implementation handles reloading by calling window.reload.

https://github.com/pikapkg/esm-hmr/blob/07bf62c2ff783a4ea3319c0b2baa3351b65c073c/src/client.ts#L16-L18

This doesn't make sense for WebExtensions where reloading is done by calling runtime.reload. I'm sure there are other contexts where you'd want to customize the reload behavior also (electron?).

It would be great if there was an API to customize the implementation of this function.

Maybe something along the lines of

import.meta.hot.registerReloadHandler(() => {
  // do your reloading here
  browser.runtime.reload()
});

The default registered reload handler could be the location one to preserve existing behavior.

Great call, let's definitely support this.

Is there a way to detect that you're in a WebExtensions environment? It could also make sense to do some simple automatic checks here, for example:

function reload() {
  if (window.browser && window.browser.runtime && window.browser.runtime.reload) {
    window.browser.runtime.reload();
  } else {
    location.reload(true); 
  }
} 

I did a bit more testing (which I should probably have done before making this issue). Coming up with good defaults here is kinda hard.

In a WebExtension there are multiple JavaScript execution contexts. There's a background script. This is the entry point of your extension. This script sticks around the entire time the browser is open. It registers hooks for interactions and can launch other kinds of scripts. There are a few types of scripts the background script can start: content scripts and scripts/pages which define extension ui. Source

When browser.runtime.reload is called from anywhere (background page, content script or chrome script), the entire extension is blown away and the extension is reloaded. All prior open tabs of pages owned by the extension are closed, the extension is reloaded and they don't come back. window.location.reload() reloads only the current context. If run from the background page, only the background page is reloaded. This could possibly lead to the background context being reloaded but none the pages, leading to some wierd behavior as two parts of the same extension could be running different versions of the code. This is based on my testing in firefox.

If a single execution context receives a message from the server telling it to invalidate() does that mean all execution contexts receive the same invalidate message or is invalidation based on some client state? My understanding is this is not the case. If all contexts are invalidated at the same time, there's not much of a difference between location.reload and browser.runtime.reload except the maybe timing and location.reload keeps more context around (isn't as jarring). If all contexts are not invalidated at the same time maybe we should allow the user to opt in to browser.runtime.reload. Maybe the user's extension is such that changes in interactions between components don't happen often and reloading independently is fine. If I were to guess this is the case most of the time. Sometimes, the user may want to configure different reload behavior.

In either case, I think the default behavior should be location.reload(true) in all cases and the user should be able to override and provide a custom reload if they desire to. Blowing away multiple windows as a default for WebExtensions seems kinda jarring and terrible, even if there may be inconsistencies in rare cases.

Your call though.

I'm on board with allowing the user to provide a custom window.HMR_INVALIDATE function, and then rewriting reload to:

function reload() {
  window.HMR_INVALIDATE ? window.HMR_INVALIDATE() : location.reload(true);
}

Although you're right to point out that invalidate can be slightly more complex than our current implementation. reloading the page is the easiest way to handle an invalid HMR update, but I believe more complex implementations can handle it by bubbling the event up to a parent and having them handle it.

Sounds perfect! I have no objections to this proposal!

@0xcaff
Hi! It seems like you're using ES Module hot reload on Web Extension for Firefox. I'm very curious about how because Firefox does not support ES Module in the content script. I'm also working on the unbundled development for our Web Extension project but slightly struggled on it because so many things won't work.

I ended up giving up on unbundled and using webpack. Webpack is mature today. Mature software can be used in non-mainstream use cases. Hot module replacement works. For my use case, I'm not willing to tradeoff time to use new technology which may be better.

Key things when using webpack:

  1. webpack.HotModuleReplacementPlugin
  2. Custom webpack-hot-middleware with explicit server URL
const webpackHotMiddlewareClient = makeHotMiddlewareEntry({
  path: "http://localhost:8080/__webpack_hmr",
});

const config: webpack.Configuration = {
  mode: "development",
  entry: {
    explore: [webpackHotMiddlewareClient, "./src/explore.tsx"],
    background: [webpackHotMiddlewareClient, "./src/background.ts"],
    contentScript: "./src/contentScript.ts",
    injectedScript: "./src/injectedScript.ts",
    iframeTrampoline: "./src/iframeTrampoline.ts",
  },
  // ...
}

// ...

function makeHotMiddlewareEntry(options: ClientOptions): string {
  const args = Object.entries(options)
    .map(([key, value]) => `${key}=${value}`)
    .join("&");

  return `webpack-hot-middleware/client${!args.length ? "" : `?${args}`}`;
}
  1. Some plugins if you want to use react hot reloading.
  plugins: [
    new WatchMissingNodeModulesPlugin(path.join(rootPath, "node_modules")),
    new webpack.HotModuleReplacementPlugin(),
    new CopyWebpackPlugin({
      patterns: [
        "src/manifest.json",
        "src/explore.html",
        "src/iframeTrampoline.html",
        { from: "src/icons", to: "icons/" },
      ],
    }),
    new ReactRefreshPlugin({
      // Disabled because when running under moz-extension:// host, tries to
      // connect to moz-extension://localhost
      overlay: false,
    }),

    // Needs to be applied after ReactRefreshPlugin. Devtools needs to be
    // imported applied before ReactRefreshPlugin, otherwise
    // __REACT_DEVTOOLS_GLOBAL_HOOK__ gets set by react-refresh/runtime and
    // react-devtools-core doesn't attach information. Also, removes
    // ReactRefreshPlugin from everything except explore.
    new RewriteEntriesPlugin(),
  ],

import * as webpack from "webpack";

function rewriteEntry(
  originalEntry: webpack.Configuration["entry"]
): webpack.Configuration["entry"] {
  if (typeof originalEntry !== "object") {
    throw new Error("unknown webpack entry object!");
  }

  return Object.entries(originalEntry)
    .map(([key, value]): [string, string[]] => {
      switch (key) {
        case "explore":
          return [key, [require.resolve("react-devtools-core"), ...value]];

        case "background":
        case "contentScript":
        case "injectedScript":
        case "iframeTrampoline":
          return [
            key,
            (value as string[]).filter(
              (entry) => !entry.includes("@pmmmwh/react-refresh-webpack-plugin")
            ),
          ];

        default:
          throw new Error("unknown entry point");
      }
    })
    .reduce(
      (acc, [key, value]) => ({
        ...acc,
        [key]: value,
      }),
      {}
    );
}

export class RewriteEntriesPlugin implements webpack.Plugin {
  apply(compiler: webpack.Compiler) {
    compiler.options.entry = rewriteEntry(compiler.options.entry);
  }
}
  1. Custom webpack invocation
import webpack from "webpack";
import webpackDevMiddleware from "webpack-dev-middleware";
import webpackHotMiddleware from "webpack-hot-middleware";

import express from "express";
import config from "../config/webpack.config";

const compiler = webpack(config);

const app = express();

app.use(
  webpackDevMiddleware(compiler, {
    publicPath: config.output!.publicPath!,

    // Loading extension files from a remote server is not possible, even when
    // changing the extension's CSP. Write to disk and use the server for hot
    // module replacement.
    writeToDisk: true,
  })
);

app.use(webpackHotMiddleware(compiler));

const port = 8080;
app.listen(port);

Let me know if that makes sense. Happy to move this conversation elsewhere (email, twitter DM?) if you need help figuring this out. Will post the source code in the future when I eventually open source this.

Hmm... Cool, I have made a whole build system around gulp.js to serve everything in ES Module, and another version that ES Module is compiled into SystemJS format for Firefox.
I have written a custom code transformer to translate node_module imports into global variable access.
I scan and bundle all my dependencies by Webpack cause snowpack (which based on rollup) cannot handle the web3.js library (and so many of our dependencies).
I even starting to develop a hot module reload solution (and it works for React Components without react-refresh) (and that's why I come to this repo cause I want to find a specification for ES Module hot reload).
I make sure dynamic import and import.meta works and so many other jobs. I'm not feeling good to found that after so many work and investigations, I have to accept there is no other choice than webpack.😢

Webpack has this same API essentially. It's called module.hot and documented here. https://webpack.js.org/guides/hot-module-replacement/

I'm pretty sure this works with ESModules and dynamic imports too. I might be using them too (not sure what ESModules are exactly tbh).

i also need this feature, i am also with nwjs, similar to electron and i need to perform location.reload() when snowpack detect change.
Can we have possibility to register a custom event maybe like this ?

import.meta.hot.setOnChange(() => {
  if('someConditions'){ location.reload() }
});

My real issue is i using special typo for React where is not compatible with HMR.
all my jsx and data are wrapped in a proxi and symbols

export const myStore= store({
  num: 0,
});

export MyModule view(() => (
  <button onClick={myStore.num++}>{myStore.num}</button>
));

More info here.
https://github.com/RisingStack/react-easy-state


For now i found my solution,considering snowpack emit log, you will probably found this funny but here the tricks for Electron and NwJs !

console._log_old = console.log;
console.log = function (msg) {
  console._log_old(msg);
   
  if (msg === '[ESM-HMR]') { // your condition logic , for me this is ok....
    location.reload();
  }
};

🤣🤢
ok is work for a temp solution but we really need a good way to register a custom reload.

If you have more clean solution, plz share.
thanks

HMR should be reloading on every update, can you share your console logs / what kind of message: XXX updates you're seeing?

Oh, I also wonder if this line is returning false in your electron environment: const isWindowDefined = typeof window !== 'undefined';

Which would then cause our normal reload function to do nothing instead of reloading:

function reload() {
  if (!isWindowDefined) {
    return;
  }
  location.reload(true);
}

@FredKSchott
Ok am not understand , following your ask, i decide to take a fully day to create you a Boilerplate to show you the issue but everything work fine by magics and support HMR with React and Nwjs !

I dont understand what wrong whit my official project where i trying to convert for now with your thecknologie!,
but everything work fine with the demo i want show you !

There may be a conversion problem (Parcel => SnowPack), I will study all of this and keep you alert.
Everything seem work fine in a clean new Boilerplate i made !
image
see =>[branch fullStack]
https://github.com/nwutils/SnowPack-nw

PS: i share the boilerplate public so you can share for student want learn how use Snowpack with Nwjs or Electron!
It seem you not have wiki on your GitHub for share boilerplate !??

Sorry friend for the inconvenience, it is probably an external problem to your technologies, i will investigate and keep you informed if is necessary.

BTW , i fully convert all my projects from Parcel to Snowpack !you are the future and plz keep doing what your do.
With Snowpack, we get reel feeling of performance when we dev ! 😁

Thank you for the kind words!