js-dojo

2 properties and attributes

GOAL

get a JavaScript object from a React page into a custom element intact, then make the same element work for a host that can only hand it text.

CONCEPT

an HTML attribute holds one kind of value - text. A DOM property holds any value at all. React 19 sets a JSX prop as a property when the element already has one by that name, and as an attribute otherwise, where setAttribute runs String() over whatever you passed: an object turns into the literal text "[object Object]" and the data is gone. React 18 and earlier set everything as attributes, which is why custom elements had a reputation for not working in React.

HINT

the property path keeps the object you passed - the same one, not a copy. Text cannot carry a reference, so the attribute path has to serialise on the way out and parse on the way in, and what comes back is a different object with equal contents.

MIRRORS

a design-system widget shipped as a custom element and handed a config object by a React page; a chart element given its rows the same way.

Run

This koan renders React, so it needs a one-time setup.

pnpm koan 10-embedded-mods/02-props-and-attributes.tsx

Source

// DOJO · Module 10 / Exercise 2 — properties and attributes
// GOAL: get a JavaScript object from a React page into a custom
//       element intact, then make the same element work for a host
//       that can only hand it text.
// CONCEPT: an HTML attribute holds one kind of value - text. A DOM
//       property holds any value at all. React 19 sets a JSX prop as
//       a property when the element already has one by that name,
//       and as an attribute otherwise, where setAttribute runs
//       String() over whatever you passed: an object turns into the
//       literal text "[object Object]" and the data is gone. React 18
//       and earlier set everything as attributes, which is why custom
//       elements had a reputation for not working in React.
// HINT: the property path keeps the object you passed - the same one,
//       not a copy. Text cannot carry a reference, so the attribute
//       path has to serialise on the way out and parse on the way in,
//       and what comes back is a different object with equal
//       contents.
// MIRRORS: a design-system widget shipped as a custom element and
//       handed a config object by a React page; a chart element given
//       its rows the same way.
// Run: pnpm koan 10-embedded-mods/02-props-and-attributes.tsx

import type { HTMLAttributes } from "react";
import { afterEach, describe, expect, it } from "vitest";
import { cleanup, render } from "@testing-library/react";

afterEach(cleanup);

type Settings = { theme: string; flags: string[] };

const SETTINGS: Settings = { theme: "dark", flags: ["beta", "sound"] };

// The element somebody else shipped. It renders whichever source it
// was given: the property if the host could set one, otherwise the
// attribute. Nothing in this class is broken.
class SettingsPanel extends HTMLElement {
  // A declared property is what makes React choose the property path:
  // it only sets a property that already exists on the instance.
  settings?: Settings;

  connectedCallback(): void {
    const settings = this.settings ?? this.settingsFromAttribute();
    this.textContent = settings ? settings.flags.join(",") : "(no settings)";
  }

  // --- TODO 2 ----------------------------------------------------------
  // Some hosts cannot set a property - a plain HTML page, a template
  // language, a framework that only writes attributes. For those, the
  // object has to travel as text. Read the data-settings attribute and
  // turn it back into a Settings object. Return null when there is no
  // attribute at all.
  settingsFromAttribute(): Settings | null {
    return null;
  }
}

customElements.define("settings-panel", SettingsPanel);

// Given: TypeScript has never heard of <settings-panel>, so the tag
// and the props a host may write are declared here.
declare module "react" {
  namespace JSX {
    interface IntrinsicElements {
      "settings-panel": HTMLAttributes<HTMLElement> & {
        settings?: Settings;
        "data-settings"?: string;
      };
    }
  }
}

// --- TODO 1 ------------------------------------------------------------
// This host can set a property, so the object should cross untouched.
// It does not pass anything at all yet. Hand SETTINGS to the element on
// the prop whose name matches the declared property above.
function PropertyHost() {
  return <settings-panel data-testid="panel" />;
}

// --- TODO 2 (host half) ------------------------------------------------
// This host writes attributes only. Send SETTINGS through data-settings
// as text that TODO 2's reader can parse back. Passing the object
// itself is what produces "[object Object]".
function AttributeHost() {
  return <settings-panel data-testid="panel" data-settings="" />;
}

describe("Module 10 / Exercise 2 — properties and attributes", () => {
  it("TODO 1 — a property carries the object itself", () => {
    const { container } = render(<PropertyHost />);
    const panel = container.querySelector("settings-panel") as SettingsPanel;

    expect(
      panel.textContent,
      "TODO 1 — the panel should list the flags it was handed",
    ).toBe("beta,sound");
    expect(
      panel.settings,
      "TODO 1 — a property carries the very object, not a copy",
    ).toBe(SETTINGS);
  });

  it("TODO 2 — an attribute carries text, so it carries a copy", () => {
    const { container } = render(<AttributeHost />);
    const panel = container.querySelector("settings-panel") as SettingsPanel;

    expect(
      panel.textContent,
      "TODO 2 — the panel should list the same flags, read from text",
    ).toBe("beta,sound");

    const parsed = panel.settingsFromAttribute();
    expect(
      parsed,
      "TODO 2 — the contents survive the trip through text",
    ).toEqual(SETTINGS);
    expect(
      parsed,
      "TODO 2 — but text cannot carry a reference, so this is a copy",
    ).not.toBe(SETTINGS);

    // Given: what the attribute would have held if the object had been
    // handed over raw. This is the failure the serialisation avoids.
    expect(String(SETTINGS)).toBe("[object Object]");
  });
});

Solution