Skip to content

Conversation

@gordonbrander
Copy link
Contributor

@gordonbrander gordonbrander commented May 21, 2024

Rendered

WIP

@gordonbrander gordonbrander changed the title UI component model rfc: UI component model May 21, 2024
@cdata
Copy link
Contributor

cdata commented May 21, 2024

It seems to be implied that components are a special case of a Module. Can you spell out the relationship more explicitly?

Comment on lines +31 to +36
- Components have **local state** (P1)
- State is encapsulated within component
- Components may pass state down to child components as input
- E.g. the React **[“lifting state up” pattern](https://legacy.reactjs.org/docs/lifting-state-up.html)**.
- Local state may be **persisted**.
- If it isn’t, it is **ephemeral**, and lasts for the lifetime of the component.
Copy link
Contributor

@bfollington bfollington May 21, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've been wondering about the semantics around "local" here.

If you have a Module named "Dropdown" expressing a UI component you have a few options for handling the "open" state:

  1. totally encapsulated: no-one but the component knows this state exists (standard React)
  2. wrapped & exported: state is held in an inner Behaviour but is exported as a readonly Event
  3. inversion-of-control: UI components only declare their state, they do not own it

If we're going to implement persisted internal state it seems like we need (3) regardless of the skinning layer that goes on top? Is there a benefit to "hiding" state vs. simply ignoring it?

An excerpt from my experiments:

// Module to generate a UI from a prompt + initial state payload
export function GeneratedUI(id, prompt, localState) {
  const render$ = new Subject();
  const generate$ = new BehaviorSubject(prompt);
  const html$ = new BehaviorSubject("");

  // map over state and create a new BehaviorSubject for each key
  const state$ = Object.keys(localState).reduce((acc, key) => {
    acc[key] = new BehaviorSubject(localState[key]);
    return acc;
  }, {});

  const generatedHtml$ = generate$.pipe(imagine(id), tap(debug));

  const ui$ = render$.pipe(
    map(() => render(id, html$.getValue(), state(state$))),
  );

  connect(generatedHtml$, html$);
  connect(html$, render$);
  Object.keys(state$).forEach((key) => {
    connect(state$[key], render$);
  });

  return {
    in: {
      render: render$,
      generate: generate$,
    },
    out: {
      ui: ui$,
      html: html$,
      ...state$,
    },
  };
}

You could argue that the html is internal state here, as no-one cares about it except this components render pipeline, but it can still be exported as an output.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

to clarify, is this code that generates a module, or on its own a module? if the latter, where are prompt and localState coming from when invoked in a recipe?

Comment on lines +40 to +42
- Components are **composable** (P1)
- Components can be combined together like lego to create larger components
- Composing plugins should be as easy as plugging together component inputs, outputs, and events, and arranging a UI tree. It shouldn’t be more complicated than that.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you envision composition where we get back one big vdom expression containing all nested children, or something more like Web Components where you register named tags and use them by name? (my expectation is the former, just asking out loud).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is composition at the recipe level (i.e. the children vdom is fed into the parent component by the runtime) or within the component (i.e. it's a big black box to the runtime)?

also, we'll want to not allow parents to inspect or change the child vdom for security reasons, but we can implement that later, e.g. by treating the children vdom as a passthrough signal and referencing that in the output vdom. that would also optimize any later vdom updates.


- UI templates are static (P3)
- They are compiled once at program start, and produce a static tree with specific “binding points” in the tree, where dynamic values and dynamic lists are rendered.
- This may have a performance advantage over a totally dynamic VDOM tree, since it would allow us to analyze and enforce policies on the tree once, rather than after every render
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Throwing it out there: if we know the types for all inputs of a control we could use a "fuzzer" approach for static policy validation even with dynamic subtrees, try a huge range of inputs etc. and ensure the output looks "safe".

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My understanding is (static) policy is enforced at RecipeModule boundaries by the Runtime, not within a JavaScript Context/CodeModule

```html
<script>
const [count, setCount] = signal(0)
export count
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is that also exporting to the recipe, i.e. ability to bind some other module to the count output?

(and how would it look like if it receives data)


// Template is turned into a UI tree with dynamic bindings
// at specific locations in the tree
const vdom = populate(template, env)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

interesting, so this could be seen in two ways:

  1. this is internal to the module (and the runtime just understands vdom + bound event streams)

  2. this is actually two modules wrapped in a mini recipe

  • the script part which is exports count and setClicks
  • the template part, which reads count and receives setClicks so it can issue events on it

it looks a bit like (2), but i have a few questions on the lifecycle then (it might actually be easier for example for the template to export setClicks and the code module to read from it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As noted in the code snippet, I'm attempting to show hypothetical "code behind the curtain" in the runtime to illustrate that this approach supports:

  • compiling a static template
  • importing signals and wiring them up to the template
  • sanitizing the resulting UI description tree
  • rendering that tree without the module having any direct access to the DOM

This code snippet was not intended to illustrate what is happening at the module or recipe level, just the kind of thing that might be happening behind the scenes in the runtime when validating/rendering the output.

@gordonbrander
Copy link
Contributor Author

gordonbrander commented May 21, 2024

It seems to be implied that components are a special case of a Module. Can you spell out the relationship more explicitly?

@cdata Added a note clarifying.

I was writing this before we settled on a terminology, and borrowing from the FE world, using the word UI component to mean:

  • an encapsulated bundle containing
    • logic
    • UI
    • Presentation
  • that presents an interface

Now that we've standardized on the word module to mean a node in a recipe, I would say that UI components are probably a special case of module.

- Easy for humans to edit
- [Maybe it’s not even code?](https://x.com/threepointone/status/1792930000766677034) Or maybe it’s a hybrid of code and plain language?
- Leverages familiar or established patterns for UI development
- Conformable with existing web FE toolchains.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does that mean / what kind of tool chains?

At a broad level I agree, but I also imagine that we'll quickly create a happy path for simple components that are whatever toolchain we pick.

### Technical goals

- Components are **encapsulated** (P1)
- A component may control its child tree, but not its siblings or parent
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It should also have limited control over its children. In particular I don't think it should be able to modify their output. And generally speaking being able to say "no information flows from child elements to their parents" will be important. Event bubbling is the tricky case there.


- Components are **encapsulated** (P1)
- A component may control its child tree, but not its siblings or parent
- Components have **inputs**, and **output UI** and **events** (P1)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not props as output (or behaviors as output).

E.g. a checkbox is I think best modeled as behavior, and it feels natural that it would be a module that outputs that.

- Allows the runtime to enforce data policies on component
- Components have **local state** (P1)
- State is encapsulated within component
- Components may pass state down to child components as input
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you spell out whether child components are internal to it or other nodes in the recipe. I assume it's the latter, but then I have two questions:

  1. presumably passing state to them is just recipes wiring
  2. where is the child component chosen? If it's in the component, then I think we're moving towards returning a recipe (this isn't difficult I think, and I think might look quite natural in the code). If it's in the recipe, then that's quite natural, but we are moving into a space where the UI assembly happens in recipes.

- State is encapsulated within component
- Components may pass state down to child components as input
- E.g. the React **[“lifting state up” pattern](https://legacy.reactjs.org/docs/lifting-state-up.html)**.
- Local state may be **persisted**.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we do just via outputs and inputs? The "pass the state to yourself" pattern? (DX might hide that of course). Spelled out as a requirement: To persist state it must be exposed as outputs & inputs.

@jsantell
Copy link
Collaborator

jsantell commented Mar 5, 2025

Closing out stale PRs, please reopen if this is relevant

@jsantell jsantell closed this Mar 5, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants