preact-ssr-prepass
preact-ssr-prepass copied to clipboard
Does not work with hooks and preact/debug
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.
@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`)
}