preactjs/prefresh

webpack: State resets for hooks

RWOverdijk opened this issue ยท 6 comments

I'm pretty sure it's a config error on my end, but I can't seem to figure it out.

When I make a change to my code it updates in the browser correctly, but only for about 300ms. After that it resets the state (useState) of my components. I'm not sure why. I've added the babel plugin as described in the readme for the webpack plugin. I'm not exporting a default function.

The only thing different about my code is that it uses a shadow dom. I've included the relevant files here.

  • preact@10.5.13
  • @prefresh/webpack@3.2.2
webpack.config.js
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const PreactRefreshPlugin = require('@prefresh/webpack');
const path = require('path');

const BUNDLE_NAME = 'widget';
const DIST_PATH = path.resolve(__dirname, 'dist');

module.exports = {
  entry: './src/index.tsx',
  output: {
    filename: `${BUNDLE_NAME}.js`,
    path: DIST_PATH
  },
  resolve: {
    extensions: ['.tsx', '.ts', '.js', '.scss'],
  },

  devServer: {
    hot: true,
    contentBase: DIST_PATH,
    compress: true,
    port: 1991,
  },

  optimization: {
    splitChunks: {
      cacheGroups: {
        styles: {
          name: BUNDLE_NAME,
          type: 'css/mini-extract',
          chunks: 'all',
          enforce: true,
        },
      },
    },
  },

  plugins: [new MiniCssExtractPlugin(), new PreactRefreshPlugin()],

  module: {
    rules: [
      {
        test: /\.scss$/i,
        use: [
          MiniCssExtractPlugin.loader,
          "css-loader",
          "sass-loader",
        ],
      },
      {
        test: /\.tsx?$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
            plugins: ['@prefresh/babel-plugin'],
            presets: [
              ['@babel/preset-typescript', {
                isTSX: true, // Prevent removal of jsx tags (confused with typescript types).
                allExtensions: true, // Required when using { isTSX: true }.
                jsxPragma: 'h' // Do not remove the `h` import because it's used for jsx.
              }],

              ['@babel/preset-react', {
                pragma: 'h' // Preact uses h() instead of React.createElement.
              }],

              ['@babel/preset-env', {
                useBuiltIns: 'entry',
                corejs: 3,
                targets: 'defaults' // Set targets (browserlist) to defaults.
              }]
            ]
          }
        }
      }
    ]
  }
};
index.tsx
import { h, render } from 'preact';
import { Widget } from './Component/Widget/Widget';
import './global.module.scss';

const fileref = document.createElement('link');

fileref.setAttribute('rel', 'stylesheet')
fileref.setAttribute('type', 'text/css')
fileref.setAttribute('href', './widget.css');

const shadowTarget = document.createElement('div');
shadowTarget.id = 'knockhello-widget';

document.body.appendChild(shadowTarget);

const shadow = shadowTarget.attachShadow({ mode: 'open' });
const shadowRoot = document.createElement('div');
shadowRoot.id = "shadow-root";

shadow.appendChild(fileref);
shadow.appendChild(shadowRoot);

render(<Widget />, shadowRoot);
Widget.tsx
import { h } from 'preact';
import type { WidgetProps } from './Widget.types';
import { WidgetStyles } from './Widget.styles';
import { Button } from '../Button/Button';
import { useCallback, useState } from 'preact/hooks';
import { Welcome } from './Component/Welcome/Welcome';

export function Widget(props: WidgetProps) {
  const [open, setOpen] = useState(false);
  const onOpen = useCallback(() => {
    setOpen(true);
  }, [setOpen]);

  return (
    <div style={WidgetStyles.container}>
      {open ? <Welcome /> : <Button text='Get help' onClick={onOpen} />}
    </div>
  );
}

Widget.tsx is what I'm using to test this right now. the open state is what seems to reset.

Hmm, we should track what that update comes from. Basically Prefresh will never trigger an update outside of you saving files as that is the trigger that Webpack uses.

How do I do that?

I just figured what could be happening, Webpack handles hot-updates a tad differently from the ESM-runtimes, lost that out of mind for a little bit as I've been a bit more active on those.

Basically when you update Widget.tsx Webpack will jump up another level into index.tsx, this makes me figure that calling render again might be resetting the state, I wonder if there's a way to test something like:

const shadowRoot = document.createElement('div');
shadowRoot.id = "shadow-root";
if (domIsPresent) {
  hydrate(<Widget />, shadowRoot);
} else {
  const fileref = document.createElement('link');

  fileref.setAttribute('rel', 'stylesheet')
  fileref.setAttribute('type', 'text/css')
  fileref.setAttribute('href', './widget.css');

  const shadowTarget = document.createElement('div');
  shadowTarget.id = 'knockhello-widget';

  document.body.appendChild(shadowTarget);

  const shadow = shadowTarget.attachShadow({ mode: 'open' });

  shadow.appendChild(fileref);
  render(<Widget />, shadowRoot);
}

For science ๐Ÿ˜… the reason webpack does this is so parent-modules can never reimport a stale child when a subsequent hot-update hits that parent. It would make sense for the state to get lost as we are always recreating that root in the index.tsx file so the option after this one might be better to confirm the assumption and making index.tsx pure might solve the issue at hand.

Rather than always recreating your root, it would be better if we'd do a few checks and only recall the root if we need to. I'm not too familiar how that would work with shadow-dom though.

Another way to try this would be to expand the depth of your tree to see if the assumption of reexecuting index.tsx is the problem here.

I tried checking if the dom element is there already but it doesn't work since the DOM seems to get removed before my index.tsx gets executed again (I tried. At the top of index.tsx there are no div nodes at all. Not even after a DOMContentLoaded). Because of this the hydrate option doesn't seem plausible to me.

I don't understand the other suggestions you gave, I'm not familiar with them. ๐Ÿ˜…

You can add a file in between to test the impact of bubbling. That being said this doesn't really sound like a Prefresh issue, the index.tsx file will be subject to a parent-renewal, the file being reinstantiated recreates your shadow-dom

Ah right. I'll just use my react template instead. Not ideal, but at least that one works ๐Ÿ˜„

Thanks for your time