preact-ssr-prepass icon indicating copy to clipboard operation
preact-ssr-prepass copied to clipboard

Does not work with hooks and preact/debug

Open patdx opened this issue 2 years ago • 1 comments

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 avatar Sep 21 '22 07:09 patdx

@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`)
}

rhengles avatar Mar 16 '23 12:03 rhengles