solid: SSR: Cannot read properties of undefined (reading 'cloneNode') because getNextElement() is called without arguments

Describe the bug

Hi,

I’m working on getting SSR and hydration to work… I’m not using solid-start because I have my own simple project for doing SSG only and for learning purposes. I started over and over again with like 4 to 5 attempts but always end up with the same error and now I think it’s less likely that it’s only me doing something wrong here…?

What I actually do is building and bundling code using esbuild, and of course with a Babel approach using esbuild-plugin-solid to make sure the JSX and SSR/DOM code generation works correctly… (the dependency uses standard solid babel transform under the hood, see: https://github.com/amoutonbrady/esbuild-plugin-solid/blob/master/src/plugin.ts#L5)

For the server code bundle I have something like this:

import { build } from 'esbuild'
import { solidPlugin } from 'esbuild-plugin-solid'

// shortened and simplified
await build({
    bundle: true,
    format: 'esm',
    platform: 'node',
    // basePlugins has some more plugins for handling importing .css files etc., nothing that would deal with js/ts stuff
    plugins: [solidPlugin({ generate: 'ssr', hydratable: true }), ...basePlugins],
  })

For the client I build another code bundle:

// shortened and simplified
await build({
    bundle: true,
    platform: 'browser',
    plugins: [solidPlugin({ generate: 'dom', hydratable: true }), ...basePlugins],
    format: 'iife',
  })

On the server I use the server code bundle and after executing the code below in a vm context:

Bildschirmfoto 2022-12-26 um 18 30 17

Please note: The render() function here is not Solid’s render function. It is my own runtime render() function that, when executed on server side, calls renderToString() and on the client a different runtime impl. is executed and calls Solid’s hydrate().

PageScript is executed on server-side and injects the client-side code bundle (this is executed on server side) by returning a <script> tag pointing to that:

Bildschirmfoto 2022-12-26 um 18 36 51

You can see the result of this down below in the generated HTML code. SSR (renderToString()) works well, the HTML code generated is served, see below:

Bildschirmfoto 2022-12-26 um 18 28 12

I was double-checking my code several times and checking against solid-ssr examples. I cannot find any big difference, except that I’m not using rollup, but esbuild – still the same Babel transform is used behind the scenes as part of esbuild-plugin-solid

But then, on the client when my runtime calls,hydrate(fn, document) I always, whatever I do since months, run into:

index.ssg.js:1814 Uncaught TypeError: Cannot read properties of undefined (reading 'cloneNode')
    at getNextElement (index.ssg.js:1814:24)
    at index.ssg.js:1997:19
    at index.ssg.js:2003:5
    at index.ssg.js:679:58
    at updateFn (index.ssg.js:49:42)
    at runUpdates2 (index.ssg.js:373:21)
    at createRoot2 (index.ssg.js:53:16)
    at render2 (index.ssg.js:677:7)
    at hydrate$1 (index.ssg.js:823:23)
    at hydrate (index.ssg.js:1060:14)

And of course this is undefined because Solid generates the following, broken code:

 // examples/homepage/src/pages/index.ssg.tsx
 var _tmpl$ = /* @__PURE__ */ template(`<div>foobar</div>`, 2);
 var _tmpl$2 = /* @__PURE__ */ template(`<script>console.log('test1')<\/script>`, 2);
 var Body = () => [getNextElement(_tmpl$), getNextElement(_tmpl$2)];
 var index_ssg_default = render(() => (() => {
   const _el$3 = getNextElement(), _el$8 = getNextMatch(_el$3.firstChild, "body"), _el$9 = _el$8.nextSibling, [_el$10, _co$2] = getNextMarker(_el$9.nextSibling), _el$11 = _el$10.nextSibling, [_el$12, _co$3] = getNextMarker(_el$11.nextSibling);
   createComponent(NoHydration, {});
   insert(_el$8, createComponent(Body, {}));
   insert(_el$3, createComponent(PageScript, {}), _el$10, _co$2);
   insert(_el$3, createComponent(LiveReload, {}), _el$12, _co$3);
   return _el$3;
 })());

Broken, because getNextElement() always expects the first argument template2 not to be undefined… but it’s generated that way for _el$3.

function getNextElement(template2) {
    let node, key;
    if (!sharedConfig.context || !(node = sharedConfig.registry.get(key = getHydrationKey()))) {
      return template2.cloneNode(true); // <== error happens in this line, template2 is undefined, see below
    }
    if (sharedConfig.completed)
      sharedConfig.completed.add(node);
    sharedConfig.registry.delete(key);
    return node;
  }

I mean, this is more or less the most basic SSR example you can make… idk, what I’m doing wrong here since months but I’m just lost and the generated code looks wrongs, so I guess opening a bug ticket is justified.

Please advice what I did wrong. I was following all the guides, read the code of the examples, the code of solid-ssr and also solid-start and couldn’t find a cure for this.

@ryansolid Is it maybe, that one cannot rehydrate the whole document from <html> down but only form a specific DOM tree node such as inside of <body>?

Thanks and best!

Your Example Website or App

https://codesandbox.io/s/flamboyant-sanne-duh3u3?file=/pages/index.ssg.js:63544-63584

Steps to Reproduce the Bug or Issue

Just visit the StackBlitz and enjoy 😃

Expected behavior

Solid would generate code with doesn’t break itself on hydrate() and that there would be some very simple example maybe for engineers to just realize what they are doing wrong just in case.

Screenshots or Videos

No response

Platform

  • All browsers, all OS

Additional context

solid-js@1.6.6 “esbuild”: “^0.15.15”, “esbuild-plugin-solid”: “^0.4.2”,

I also tried different versions of Solid, but nothing helps.

About this issue

  • Original URL
  • State: closed
  • Created 2 years ago
  • Comments: 20 (7 by maintainers)

Most upvoted comments

I did end up mocking hydrate so with 1.6.7 onward you don’t need to lazily import it to work.

Glad you are set now.

I just meant fixing user stuff on your side. I was doing everything in the browser in codesandbox I modified the variable values while it was running to swap the element, set the context, and populate _el$13 (which was the one screwed up by the script tag in the body) and everything was good.

Looking at the codesandbox again now that it is updated we are back at two copies of Solid. There is a getNextElement and getNextElement2 which is why they aren’t seeing each other as one is being used in your hydrate call and the other injected by the compiler. Looking closer it looks like both a ESM and CJS version of solid web are getting included.

Yeah it’s due to the import cjs style in the entry. I tried changing the runtime.ts to:

import type { JSX } from "solid-js";
import { isServer } from "solid-js/web";

export const renderPage = (Component: () => unknown): () => JSX.Element => 
    isServer ? Component : import("solid-js/web").then((m) => m.hydrate(Component, document));

And move the script tag inside the body and it works but it is unable to treeshake solid-js/web if we do that.

What I should do is stub out the hydrate and render calls in the server build. Rollup is smart enough to see isServer is constant and dead code eliminate out the missing branches and imports but it looks like ESBuild is not and tries to pull hydrate in even when it doesn’t exist on the server if we don’t do the dynamic import.

Ok looking at this I’ve found a few things.

  1. For some reason while the code is updated the code running in the browser still isn’t updated. I’m seeing it trying to hydrate the body. Maybe the build is cached or something.
  2. context.pageClientScriptPath is undefined … context is undefined which causes a can’t access pageClientScriptPath of undefined error. I’m gathering this is intended to be server only but it gets hydrated. Now arguably I can fix this with Solid to only do whats in the body. I hadn’t considered this case because a script tag isn’t allowed to be outside of the body or head. Which brings me to:
  3. The final script tag is outside of the body. The browser corrects it to be inside but it messes with things a bit since the JSX walker is looking for it in the original location and finds null instead which provides a 3rd error.

When I correct all three of these things the page hydrates.

I see. Bundling the client twice is definitely an issue for Solid because reactivity works off a singleton as does the server context we use during SSR. I was hoping that was all it is. Now we are more likely dealing with a hydration mismatch. The ids start from the mount point. So as long as the server and client mount from the same element and render the same stuff we should be a match.

What I’d really wish for would be that one could simply render a whole HTML page and solid would be smart enough not to hydrate <html> <head> and <body> at all, simply ignoring those tags in case of hydration.

It does but it needs to start the counter and have it match up. It skips the head etc… but we need to be able to match ids and the id slotting happens at compiler time. I could be a bit smarter with html probably which might simplify some things with people starting from a mismatch to begin with. But depending on how crazy people get with breaking apart their templates I imagine there would still be edge cases. I can only detect the content of a given string at compiler time and not how it will be inserted.

Ok looking at the very first example there are 2 copies of Solid being pulled in which would mess everything up, completely botch hydration.

I’m gathering the same is happening in the new server build because I see solid code in dist, but your build script is also importing solid code from a different location. It needs to set it as external or put renderToString in the built part.

const baseSSRConfig: BuildOptions = {
  bundle: true,
  outdir: 'dist', // dist/_document.js
  format: 'esm',
  platform: 'node',
  sourcemap: 'external',
  external: ["solid-js", "solid-js/web"]
}

That gets it rendering.

I haven’t had a chance to look at this yet, but some perhaps useful info in the meantime. Certain templates can’t be cloned anyway. Like ones that contain <html> so compiled code looks mostly correct to me. It should never be cloned in its whole lifetime because it should only be hit once, during hydration and it should match on hydration IDs. This mismatch is the source of the problem, but I will need to look why it is off. That sharedRegistry.get should never miss during hydration.