preactjs / preact-ssr-prepass

Drop-in replacement for react-ssr-prepass
49 stars 7 forks source link

Does not work with hooks and preact/debug #48

Open patdx opened 2 years ago

patdx commented 2 years ago

I was trying to use react-router, preact and preact-ssr-prepass together and ran into some issues. After eliminating an unrelated ESM/CJS error, I discovered that I was still running into issues with preact-ssr-prepass. It turns out it can be reproduced with a pretty short example:

import 'preact/debug'; // if you comment this line out, it will work
import { h } from 'preact';
import renderToString from 'preact-render-to-string';
import prepass from 'preact-ssr-prepass';

import { useState } from 'preact/hooks';

const App = () => {
  const [x, setX] = useState(10);
  console.log(`Use state inside of App`, x);
  return h('div', undefined, x);
};

const vnode = h(App);

console.log('begin prepass');
await prepass(vnode);
console.log('end prepass');

const out = renderToString(vnode);

console.log(out);

Output:

❯ node index.js
begin prepass
Error: Hook can only be invoked from render methods.
    at async ESMLoader.import (https://node-qpdrhr-230n5kih.w.staticblitz.com/blitz.f2b7d4e326e0543f39833cc6d890b02bb01d7899.js:6:1209283)
    at async i.loadESM (https://node-qpdrhr-230n5kih.w.staticblitz.com/blitz.f2b7d4e326e0543f39833cc6d890b02bb01d7899.js:6:246622)
    at async handleMainPromise (https://node-qpdrhr-230n5kih.w.staticblitz.com/blitz.f2b7d4e326e0543f39833cc6d890b02bb01d7899.js:6:989292)

Here is a stackblitz: https://stackblitz.com/edit/node-qpdrhr?file=index.js


Basically as far as I can tell preact/debug is throwing an error because when the useState() hook is called, hooksAllowed is false. I guess this is related to the timing of the component lifecycle. I think the error from preact/debug seems mistaken because when I comment out that preact/debug import any code I write seems to work fine.

I wonder if this will be fixed by https://github.com/preactjs/preact-ssr-prepass/pull/47? Because it seems that options._diff function will set hooksAllowed = true.

The obvious solution may be to "not use preact/debug on the server side", which I think makes sense. But, in my case I was trying to set up an SSR project with @preact/preset-vite, which has the preact/debug hardcoded in so I never had a choice or knew it was being imported. I'm going to see if I can override it and skip the preact/debug import for the server side render.

rhengles commented 1 year ago

@patdx Bizarrely, I think the problem is in the minification. I already got a similar problem with using react-redux with preact, vite and ssr. A bundle of react-redux minified with Terser, with all default options, simply does not work. Then I tried a very basic online minifier and it worked.

My solution to this problem was the following: I patched the preact/debug package to load the file ./debug/src/index.mjs instead of the default ./debug/dist/debug.mjs and I had to change all the source extensions from .js to .mjs. Maybe it would have been simpler to just add type: "module" to the package since I don't need cjs, lol.

This is my patch script:

import { resolve as pathResolve, join as pathJoin } from 'node:path'
import { fileURLToPath } from 'node:url'
import { rename } from 'node:fs'
import { readFile, writeFile } from 'node:fs/promises'
import dirFiles from 'dir-files'
import { extendDeepModify } from '@arijs/frontend/isomorphic/utils/extend'

const dirname = fileURLToPath(new URL('../', import.meta.url)).replace(/\/+$/,'')

renameExts(() => modifyPreactPkg(() => console.log(`preact/debug patched`)))

function renameExts(cb) {
    var dfp = dirFiles.plugins;
    var pluginOpt = {};

    const srcDir = 'node_modules/preact/debug/src'
    const reExtFrom = /\.js$/i
    const extFrom = '.js'
    const extTo = '.mjs'
    console.log(`renaming files in ${srcDir} from ${extFrom} to ${extTo}`)

    dirFiles({
        result: 0,
        path: pathResolve(dirname, srcDir),
        plugins: [
            dfp.skip(function skipSpecial({ name }) {
                return name.startsWith('.')
                    || name.startsWith('$')
                    || name.startsWith('node_modules');
            }),
            dfp.stat(pluginOpt),
            dfp.queueDir(pluginOpt),
            dfp.readDir(pluginOpt),
            dfp.queueDirFiles(pluginOpt),
            dfp.skip(function skipEmptyNameOrDir({ name, stat }) {
                return !name || stat.isDirectory() || !name.endsWith(extFrom);
            }),
            function renameFile({ dir, name: nameFrom }, cb) {
                const nameTo = nameFrom.replace(reExtFrom, extTo)
                const from = pathJoin(dir.root, dir.sub, nameFrom)
                const to = pathJoin(dir.root, dir.sub, nameTo)
                console.log(`~ ${pathJoin(dir.sub, nameFrom)} -> ${nameTo}`)
                this.result++
                rename(from, to, cb)
            },
        ],
        onError: function(err, { dir, name }) {
            console.log('! '+pathJoin(dir.sub, name));
            console.error(err);
        },
        callback: function(err) {
            if (err) {
                throw err;
            }
            console.log(`${this.result} files renamed`)
            cb()
        },
    });
}

function modifyPreactPkg(cb) {
    const prom = runPackage('preact', () => ({
        'exports': {
            './debug': {
                'import': "./debug/src/index.mjs"
            }
        }
    }))
    prom.catch(error => { throw error })
    prom.then(cb)
}

async function runPackage(name, fnAdditions) {
    const fOpt = {
        encoding: 'utf8',
        highWaterMark: 1024,
    }
    const pkgDir = `node_modules/${name}/package.json`
    const pkgPath = pathResolve(dirname, pkgDir)
    const pkgSource = await readFile(pkgPath, fOpt)
    const pkg = JSON.parse(pkgSource)
    const additions = fnAdditions(pkg)
    extendDeepModify(pkg, additions)
    const pkgTarget = JSON.stringify(pkg, null, '\t')
    const sizeFrom = pkgSource.length
    const sizeTo = pkgTarget.length
    const sizeDiff = sizeTo - sizeFrom
    await writeFile(pkgPath, pkgTarget, fOpt)
    console.log(`Package ${pkgDir}`)
    console.log(` overwrite ${JSON.stringify(additions)}`)
    console.log(`Diff: ${sizeDiff} from ${sizeFrom} to ${sizeTo} bytes`)
}