WebReflection / uhtml

A micro HTML/SVG render

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

This sequence of renders crashes in both V3 and V4

gbishop opened this issue · comments

I'm monkey testing my app and have uncovered a sequence of renders that will crash both V3 and V4. I apologize for the complexity of this example; this is the shortest sequence of renders that I have found that causes it.

The error is

DOMException: Failed to execute 'setStartAfter' on 'Range': the given Node has no parent.
at range_default (http://localhost:5173/node_modules/.vite/deps/uhtml.js?v=3ae075b9:68:11)
at remove (http://localhost:5173/node_modules/.vite/deps/uhtml.js?v=3ae075b9:77:55)
at DocumentFragment.replaceWith (http://localhost:5173/node_modules/.vite/deps/uhtml.js?v=3ae075b9:106:5)
at Object.hole (http://localhost:5173/node_modules/.vite/deps/uhtml.js?v=3ae075b9:271:9)
at unroll (http://localhost:5173/node_modules/.vite/deps/uhtml.js?v=3ae075b9:425:21)
at unrollValues (http://localhost:5173/node_modules/.vite/deps/uhtml.js?v=3ae075b9:436:19)
at unroll (http://localhost:5173/node_modules/.vite/deps/uhtml.js?v=3ae075b9:411:18)
at unrollValues (http://localhost:5173/node_modules/.vite/deps/uhtml.js?v=3ae075b9:436:19)
at unrollValues (http://localhost:5173/node_modules/.vite/deps/uhtml.js?v=3ae075b9:438:7)
at unroll (http://localhost:5173/node_modules/.vite/deps/uhtml.js?v=3ae075b9:411:18)

start.js

import { html, render } from "uhtml";
import { data } from "./data.js";

/**
 * This tree is a very simplified version of my internal data structure
 * @typedef {Object} TreeBase
 * @property {string} className
 * @property {Object} props
 * @property {TreeBase[]} children
 * @property {string} id
 *
 * @typedef {import("uhtml").Hole} Hole
 */

/**
 * Wrap the code for a node into a component
 * @param {TreeBase} node
 * @param {Hole} body
 * @returns {Hole}
 */
function component(node, body) {
  return html`<div class=${node.className} id=${node.id} style=${""}>
    ${body}
  </div>`;
}

/**
 * A Stack has mulitple children
 * @param {TreeBase} node
 * @returns {Hole}
 */
function Stack(node) {
  return component(
    node,
    html`${node.children.map((child) => html`<div>${content(child)}</div>`)}`,
  );
}

/**
 * A Gap is simply a spacer
 * @param {TreeBase} node
 * @returns {Hole}
 */
function Gap(node) {
  return component(node, html`<div />`);
}

/**
 * Invoke the correct template for each node
 * @param {Object} node
 * @returns {Hole}
 */
function content(node) {
  if (node.className == "Gap") return Gap(node);
  else if (node.className == "Stack") return Stack(node);
  throw new Error("should not happen");
}

// cycle through the trees

let index = 0;
let step = 1;

function rep() {
  console.log({ index });
  try {
    render(document.body, content(data[index]));
  } catch (e) {
    console.error(e);
    clearInterval(timer);
  }
  index += step;
  index = index % data.length;
}
const timer = setInterval(rep, 100);

data.js: this defines the sequence of frames that cause the problem. Each array element is a tree.

export const data = [
  {
    // 9
    className: "Stack",
    children: [
      {
        className: "Stack",
        children: [
          {
            className: "Stack",
            children: [],
            id: "TreeBase-117",
          },
          {
            className: "Stack",
            children: [],
            id: "TreeBase-118",
          },
          {
            className: "Stack",
            children: [
              {
                className: "Stack",
                children: [],
                id: "TreeBase-122",
              },
            ],
            id: "TreeBase-121",
          },
        ],
        id: "TreeBase-5",
      },
    ],
    id: "TreeBase-4",
  },
  {
    // 8
    className: "Stack",
    children: [
      {
        className: "Stack",
        children: [
          {
            className: "Stack",
            children: [],
            id: "TreeBase-117",
          },
          {
            className: "Stack",
            children: [],
            id: "TreeBase-118",
          },
          {
            className: "Stack",
            children: [],
            id: "TreeBase-121",
          },
        ],
        id: "TreeBase-5",
      },
    ],
    id: "TreeBase-4",
  },
  {
    // 7
    className: "Stack",
    children: [
      {
        className: "Stack",
        children: [
          {
            className: "Stack",
            children: [
              {
                className: "Gap",
                children: [],
                id: "TreeBase-120",
              },
            ],
            id: "TreeBase-117",
          },
          {
            className: "Stack",
            children: [],
            id: "TreeBase-118",
          },
          {
            className: "Stack",
            children: [],
            id: "TreeBase-121",
          },
        ],
        id: "TreeBase-5",
      },
    ],
    id: "TreeBase-4",
  },
  {
    // 6
    className: "Stack",
    children: [
      {
        className: "Stack",
        children: [
          {
            className: "Stack",
            children: [
              {
                className: "Gap",
                children: [],
                id: "TreeBase-120",
              },
            ],
            id: "TreeBase-117",
          },
          {
            className: "Stack",
            children: [],
            id: "TreeBase-118",
          },
          {
            className: "Gap",
            children: [],
            id: "TreeBase-119",
          },
          {
            className: "Stack",
            children: [],
            id: "TreeBase-121",
          },
        ],
        id: "TreeBase-5",
      },
    ],
    id: "TreeBase-4",
  },
];

there are random errors due random index.html in your example but I've changed it as such: would you say this works as expected?

import { html, render } from "uhtml";
import { data } from "./data.js";

/**
 * This tree is a very simplified version of my internal data structure
 * @typedef {Object} TreeBase
 * @property {string} className
 * @property {Object} props
 * @property {TreeBase[]} children
 * @property {string} id
 *
 * @typedef {import("uhtml").Hole} Hole
 */

/**
 * Wrap the code for a node into a component
 * @param {TreeBase} node
 * @param {Hole} body
 * @returns {Hole}
 */
 function component(node, body) {
  return html`<div class=${node.className} id=${node.id} style=${"border: 1px solid red; padding: 2px;"}>
    ${[].concat(body)}
  </div>`;
}

/**
 * A Stack has mulitple children
 * @param {TreeBase} node
 * @returns {Hole}
 */
function Stack(node) {
  return component(
    node,
    node.children.map((child) => html`<div>${content(child)}</div>`),
  );
}

/**
 * A Gap is simply a spacer
 * @param {TreeBase} node
 * @returns {Hole}
 */
function Gap(node) {
  return component(node, html`<div />`);
}

/**
 * Invoke the correct template for each node
 * @param {Object} node
 * @returns {Hole}
 */
function content(node) {
  if (node.className == "Gap") return Gap(node);
  else if (node.className == "Stack") return Stack(node);
  throw new Error("should not happen");
}

// cycle through the trees

let index = 0;
let step = 1;

function rep() {
  console.log({ index });
  try {
    render(document.body, content(data[index]));
  } catch (e) {
    console.error(e);
    clearInterval(timer);
  }
  index += step;
  index = index % data.length;
}
const timer = setInterval(rep, 500);

There are known regressions on V4 due polished layout that makes some previous case not possible due the way fragment works ... most precisely, removing the comment and the diffing all over caused some sedge case to not work.

In this specific example, you have generic holes but then suddenly you have a hole just to propagate an Array and that's a violation of current logic: once array, always array.

I do think the hole wrap around the array might be an escape hatch but it's still ignoring the fact that growing and shrinking lists or content MUST be an Array so that once you make everything an array when such case is known, you should be good.

Alternatively, don't use this:

html`${array}`

but use this instead:

html`<div>${array}</div>`

In this case your body is always a hole instead of a hole carrying an array so that no issue should happen.

In short, I think I might have made uhtml slightly less friendly but it's actually forcing even myself to better think about layouts: you can't trash random stuff in it, you need to be sure if a hole can have a list of nodes in it, it's either always an array or its template wrap an array confined within a specific node instead.

I hope this makes sense, looking forward to hear from you about the correctness of the changed example.

P.S. to dig further:

html`${html`?`}`

is also a shenanigan ... a Hole is meant to represent a node being this a fragment or a node (text or comment or generic) it's gotta be a node or fragments of fragments completely lose their ability to add/diff/remove themselves because their nodes get merged together ... with <!--comments--> per each hole this is less of an issue because the comment stays and can be embedded in outer holes, hence added or remove on occasion, but that means an overly polluted layout full of comments that for long time everyone complained about, so that comments exist only for known arrays / shrinking-or-growing parts.

Mixing these might be solvable from my side, but I wonder if it should be resolved from my side.

I am thinking to write a Known Caveats or Troubleshooting section that explains that fragments in fragments without boundaries cannot work and that holes carrying arrays are an anti-pattern too ... we should always know before defining components or layouts which area might eventually grow and which one won't ever change if not for content (not growing list of data).

OK. I think I understand. My error was thinking that html${array} was sufficient to convert an array to a Hole. I remember wondering about that in the past.

Now to decide if I want to convert everything to array or wrap the occasional array with a div.

I have considered writing a runtime checker that wraps uhtml and looks for these kinds of errors. I think it could be fairly simple and would prevent these kinds of mistakes.

I'm off now to make these changes to the app and see what happens.

Thanks for your help.

My error was thinking that html${array} was sufficient to convert an array to a Hole

it's a sort of hack, if you think about it ... if "once array, always array" (and bear with me I am talking V4 only, I haven't even checked V3), it's true the other way around: once a hole, always a hole.

A hole can be:

  • a DOM node
  • a fragment (handled as persistent fragment in here)
  • a string, a number, null, undefined, a boolean, anything else that is not an instanceof Hole and / or not a DOM node

If you use a hole to represent an array what/how can I pin that information suddenly unexpected in the logic?

In V3 everything had a comment node and everything was always diffed but technically, and for my use cases practically too, in no circumstance I create templates without knowing if the part of these will be an array or not, so I felt free to remove those ugly comments all over the place and/or the need to use udomdiff for everything ... and perf and RAM confirmed it was the right decision.

Now I am also playing with a linear parser that never loops over values more than once, it's already faster, but if anything it might bring even more constraints (reason it's nowhere in this repo, I need time to be sure that's where I want to be) ... but you get the gist of my project here: zero ambiguity and robust rules that guarantee best performance and reduced memory consumption by all means.

The "maybe array" to me was a mistake and easy to solve by using isArray(hole) ? hole : [hole] when necessary or just [].concat(hole) if-ever this can be at some point a list of nodes.

I want to test all cases work as expected too so there's time to either discuss, fix, or improve the current state, and yet I think html${array} is a hack/workaround to the contract of V4 API.

My guess is that many users would be very pleased to trade off speed for bullet proofness. I know I would. My whole interface typically draws in less than 10ms on a ChromeBook!

I wonder if I could use a WeakMap to map a Template to a vector describing the shapes of the values being interpolated. I could throw an error is I saw a Hole become an Array for example. I could complain about a Hole wrapping a naked array. As I found stumbling blocks, I'd add them to the list.

It would slow things down but computer time is almost always less valuable than my time.

Crash free now.

I went with making my component wrapper (content in the simple example) accept Hole or Hole[] and used [Hole] before passing to the interpolation.

I had half a dozen places where I had html${thing} and they were mostly simple to fix.

Thanks for your help.

My guess is that many users would be very pleased to trade off speed for bullet proofness.

👍
(Me too)

Update (one gotta sleep over complex matter sometimes) ... the need for a comment might be me overlooking at the elephant in the room: an empty text node would do just fine too!

It's true that if anyone invokes a container.normalize() operation I might lose those nodes but that's always been the case, even with a text node that as just no content in it so that .normalize() should be simply avoided/unnecessary/forbidden.

I am not sure I want to drop comments from places where arrays are meant because that's a clear indication of where arrays exist but I can also keep, and empty, text nodes around and have more use cases addressed (I think) by doing that.

Like I've said, I am still working on yet another improvement and maybe it won't bring a huge jump in performance but probably will bring a better DX ... still cooking though.

You said:

I am thinking to write a Known Caveats or Troubleshooting section that explains that fragments in fragments without boundaries cannot work and that holes carrying arrays are an anti-pattern too ... 

That would be very helpful. I'd like to make a list of "foot guns" so I can add them to a run-time wrapper that verifies they are not present.

Here is the beginning of my list:

  1. An interpolated parameter must keep the same type (string, boolean, number, function, Hole or Hole[]) for every use of the template.
  2. html${thing} is bad
  3. The template string must begin with a html tag. (Is this too strong?)

Are there others?

// this is NOT bad in general
html`${thing}`

// this is (currently) bad
html`${html`${thing}`}`

The template string can start with either html or svg as both are valid and the latter one creates SVG content instead.

The content of html or svg cannot be a function right now, only special attributes (i.e. ref) can contain a function, holes in the content cannot be a function in V4.

This might change soon thoughj so please wait for me to resolve this and the other issue as these are somehow related (the fragment in fragment one).

// this is NOT bad in general
html`${thing}`

Can you give me an example of when it is not bad? html`${array of Holes}` is apparently bad. html`${HOLE}` is bad.

In my point 3, I meant the content of the template string, not the template tag itself. So I'm suggesting that a template string has to be something like html`<div>stuff</div>` and not simply html`some arbitrary text` . Am I wrong about that?

I'll try to answer this evening as I might have a "good solution fit them all" thing but I need to test it ... until I am sure how I want to resolve these issues there's not much point in defining anything as even those cases might be allowed, as it used to be in V3. A bit of patience, right now I think you perfectly got how the current revision works though 👍

I whipped up a hack wrapper for uhtml to help me find a bug. Monkey testing turns up crazy stuff. I'm likely too strict and missing cases. I called it UHTML.js.

import { html as _html } from "uhtml";

export { render } from "uhtml";

const typeMap = new WeakMap();

/** @param {TemplateStringsArray} strings
 * @param {any[]} args
 */
export function html(strings, ...args) {
  let types = typeMap.get(strings);
  if (!types) {
    types = args.map((arg) => getTypeOf(arg));
    typeMap.set(strings, types);
  }

  if (!strings[0].match(/\s*</)) {
    throw new Error(`html does not begin with < ${strings[0]}`);
  }

  args.forEach((arg, index) => {
    const string = strings[index];
    if (!string.endsWith("=") && !string.endsWith('="')) {
      // must be a content node
      if (arg === null) {
        throw new Error(`html arg after ${string} is null`);
      }
      if (arg === undefined) {
        throw new Error(`html arg after ${string} is undefined`);
      }
      const atype = getTypeOf(arg);
      if (atype != types[index]) {
        const t = types[index];
        if (
          !atype.startsWith("Array") ||
          !t.startsWith("Array") ||
          !(atype.endsWith("empty") || t.endsWith("empty"))
        )
          throw new Error(
            `type of arg after ${string} changed from ${types[index]} to ${atype}`,
          );
      }
    } else {
      // must be an attribute
      const atype = getTypeOf(arg);
      if (
        atype != types[index] &&
        atype != "undefined" &&
        types[index] != "undefined" &&
        atype != "null" &&
        types[index] != "null"
      ) {
        throw new Error(
          `attribute ${string} changed from ${types[index]} to ${atype}`,
        );
      }
    }
  });
  return _html(strings, ...args);
}

/** @param {any} arg
 * @returns {string} */
function getTypeOf(arg) {
  const t = typeof arg;
  if (t != "object") return t;
  if (arg == null) return "null";

  if (Array.isArray(arg)) {
    if (arg.length) {
      const ts = arg.map((a) => getTypeOf(a));
      if (!ts.every((t) => t == t[0])) {
        return `Array of ${ts[0]}`;
      } else {
        console.error("array", ts);
        throw new Error("Array elements of different types");
      }
    } else return `Array empty`;
  }
  if (arg.constructor.name == "Hole") {
    return "Hole";
  }
  return "object";
}

Latest uhtml fixed the presented use case and more. Passing holes into holes is now accepted and welcomed as those are handled as persistent fragments because that's what they are, since there's no other way to pin their details in the wild.

Please read notes in this MR #105 to understand how the logic works now and expect layout changes as that's inevitable and for good too ... fragments are still not suggested in general, but as these used to work reliably, all I could do was to bring these back in an even better shape.

To whom it might concern, the MR to update the well known benchmark is here:
krausest/js-framework-benchmark#1576

Right now, I can see with keyed results that latest is scoring 1.00 VS 1.02 while non-keyed is mostly the same, but on memory consumption it wins by 0.01 margin in both cases, last time I've checked.