-
Notifications
You must be signed in to change notification settings - Fork 9
rfc: UI component model #8
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
|
It seems to be implied that components are a special case of a Module. Can you spell out the relationship more explicitly? |
| - 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. |
There was a problem hiding this comment.
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:
- totally encapsulated: no-one but the component knows this state exists (standard React)
- wrapped & exported: state is held in an inner Behaviour but is exported as a readonly Event
- 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.
There was a problem hiding this comment.
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?
| - 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. |
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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".
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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:
-
this is internal to the module (and the runtime just understands vdom + bound event streams)
-
this is actually two modules wrapped in a mini recipe
- the script part which is exports
countandsetClicks - the template part, which reads
countand receivessetClicksso 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.
There was a problem hiding this comment.
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.
@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:
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. |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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:
- presumably passing state to them is just recipes wiring
- 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**. |
There was a problem hiding this comment.
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.
- Include serialized inputs/outputs - Clarify inputs/outputs
|
Closing out stale PRs, please reopen if this is relevant |
Rendered
WIP