Skip to content

feat: runtime component creation via Rhai and PluginContext - #89

Merged
geoffjay merged 15 commits into
mainfrom
sfc-app
Aug 6, 2026
Merged

feat: runtime component creation via Rhai and PluginContext#89
geoffjay merged 15 commits into
mainfrom
sfc-app

Conversation

@geoffjay

@geoffjay geoffjay commented Aug 6, 2026

Copy link
Copy Markdown
Owner

Summary

Implements the runtime component creation plan — Layer 1: let handlers and scripts create and remove built-in component instances at runtime, via Rhai and PluginContext.

Apps that need structurally dynamic UI (form builders, list-of-records editors, conditionally-composed dashboards) no longer need to pre-declare every possible component and toggle visible.

Changes

Core API (LayoutManager + PluginContext + RuntimeContext)

Four new functions exposed to Rhai, PluginContext, and WASM:

create_component(parent_id, type, props)            // returns generated __dyn_N id
create_component_with_id(parent_id, id, type, props)
update_component(id, props)                          // bulk property set
remove_component(id)                                  // recursive subtree + binding teardown
  • LayoutManager (nemo-layout/src/manager.rs): insert_component (validates type via registry, rejects duplicate IDs, appends to parent children), remove_component (recursive subtree teardown with binding cleanup, root guard), set_properties (bulk update), generate_dynamic_id (__dyn_N monotonic counter). 12 unit tests.
  • PluginContext trait (nemo-plugin-api/src/lib.rs): 4 new methods with default Err(Unsupported) so existing plugin SDKs compile unchanged.
  • RuntimeContext (nemo/src/runtime.rs): implements all 4 via try_write lock + data_dirty/data_notify re-render path. 6 integration tests.
  • Rhai (nemo-extension/src/rhai_engine.rs): registers the 4 functions in register_context. 1 end-to-end test (click handler creates a label, appears in snapshot, remove cleans up).
  • WASM (nemo-wasm-guest/wit/nemo-plugin.wit + nemo-wasm/src/host_impl.rs): 4 WIT host functions + delegating implementations.

Knowledgebase updates

  • concepts/components.md — new "Runtime component creation" section
  • concepts/data-flow.mdcreate_component noted as re-render trigger
  • patterns/runtime-component-creation.md — new pattern doc
  • patterns/index.md — links the new pattern
  • nemo-xml-reference skill — documents the no-<binding> limitation

SFC example demo (examples/sfc)

A fourth card with "Add label" / "Remove last" buttons backed by scripts/handlers.rhai — demonstrates create_component and remove_component at runtime. Validates clean with nemo validate --strict.

Design decisions

  • No <binding> support (by scope): dynamic components use explicit Rhai handlers or set props at creation time.
  • State leak (v1): ComponentStates entries are lazily leaked on removal — keyed by ID, harmless, bounded by app churn. A follow-up would wire App-side cleanup.
  • BindingManager::unbind_component already existed — used directly instead of adding a new remove_for_component method as the plan assumed.
  • Root guard: remove_component refuses to remove the component returned by root_id().

Test plan

  • 12 LayoutManager unit tests (insert with/no parent, unknown type, duplicate ID, recursive remove, root guard, binding cleanup, bulk update, dynamic ID)
  • 6 RuntimeContext integration tests (create appears in snapshot, explicit ID, unknown type rejected, remove cleans up, root refused, bulk update)
  • 1 Rhai end-to-end test (create on click → appears in snapshot → remove cleans up)
  • nemo validate --strict examples/sfc/app.xml passes
  • All 245 nemo + 44 layout + 41 extension tests pass

Note: test_dev_dashboard_refresh_all_degrades_without_process in nemo-extension fails when pkg-env is enabled — this is a pre-existing rhai-env issue unrelated to this PR.

Add split_sfc_blocks, a depth-aware pre-pass in parse_sfc that captures
top-level <script>/<style> bodies verbatim before quick-xml sees them,
treating them as HTML-style raw-text elements. Rhai &&/</> and CSS >
combinators no longer need <![CDATA[…]]>; a wrapper is stripped if present
so existing CDATA-wrapped files parse unchanged.

Migrate examples/sfc/*.nemo to CDATA-free as the definitive reference:
labeled-button exercises && and > in <script>, card exercises the >
combinator in <style>. Update the SFC pattern doc, configuration concept,
plan status, and roadmap index. The on-disk regression test now asserts the
examples are CDATA-free and exercise raw-text features.
Let handlers and scripts create/remove built-in component instances at
runtime through four new API functions: create_component,
create_component_with_id, update_component, and remove_component.

LayoutManager gains insert_component (validates type via registry,
rejects duplicate IDs, appends to parent children), remove_component
(recursive subtree teardown with binding cleanup, root guard),
set_properties (bulk update), and generate_dynamic_id (__dyn_N counter).

PluginContext trait adds the four methods with default Err(Unsupported)
so existing plugin SDKs compile unchanged. RuntimeContext implements
them through the existing try_write + data_dirty re-render path.

Rhai registration exposes the functions to scripts; a click handler can
now create a label that appears in the layout snapshot and remove it
later. WASM WIT and host_impl gain matching host functions.

State entries are lazily leaked (v1) — keyed by ID, harmless, bounded by
app churn. No <binding> support (by scope decision).
Complete the plan's 'knowledgebase updates required when implemented':
- components.md: new 'Runtime component creation' section
- data-flow.md: note create_component as a re-render trigger
- patterns/runtime-component-creation.md: new pattern doc
- nemo-xml-reference skill: document the no-<binding> limitation

Add a working demo to examples/sfc: a fourth card with 'Add label' and
'Remove last' buttons backed by a handlers.rhai script that calls
create_component and remove_component at runtime. Validates clean with
nemo validate --strict.
@codecov

codecov Bot commented Aug 6, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 75.51669% with 154 lines in your changes missing coverage. Please review.
✅ Project coverage is 58.04%. Comparing base (2cafb36) to head (5d6db20).

Files with missing lines Patch % Lines
crates/nemo-layout/src/list_binding.rs 76.75% 43 Missing ⚠️
crates/nemo-config/src/directives.rs 88.33% 21 Missing ⚠️
crates/nemo-layout/src/binding.rs 16.66% 20 Missing ⚠️
crates/nemo-wasm/src/host_impl.rs 0.00% 19 Missing ⚠️
crates/nemo-extension/src/rhai_engine.rs 30.76% 18 Missing ⚠️
crates/nemo-layout/src/manager.rs 85.22% 13 Missing ⚠️
crates/nemo-plugin-api/src/lib.rs 0.00% 9 Missing ⚠️
crates/nemo-config/src/xml_parser.rs 92.94% 6 Missing ⚠️
crates/nemo-config/src/value.rs 37.50% 5 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main      #89      +/-   ##
==========================================
+ Coverage   56.53%   58.04%   +1.50%     
==========================================
  Files          79       81       +2     
  Lines        7019     7629     +610     
==========================================
+ Hits         3968     4428     +460     
- Misses       3051     3201     +150     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

geoffjay added 11 commits August 5, 2026 23:05
Implements Phase 3 of the control-flow-directives plan (live-data n:for
runtime list binding) together with the shared compile_directives pass it
depends on (Phases 1 & 2).

Compile-time (nemo-config):
- New directives.rs: compile_directives pass, run in load_xml_string before
  resolution. n:if -> bind-visible binding (comparisons compile to a binding
  block with a == / != transform; bare paths compile to bind_visible truthiness).
  Static n:for -> expands a child into N sibling nodes with ${item} substitution
  and index/n:key id suffixing. Live-data n:for (data.*) -> list_binding metadata.
- Resolver skips list_binding subtrees (runtime ${item.*} placeholders).
- Value gains as_object_mut / as_array_mut.

Runtime (nemo-layout):
- apply_transform: == <lit> / != <lit> comparison transforms returning Bool.
- ListBindingSpec on ComponentConfig/LayoutNode.
- New list_binding.rs: ListBindingManager. Source-path matching (exact + nested
  prefix so HTTP data.api populates data.api.users), keyed diff with stable
  <container>_<key> ids (reuse + reorder, preserving widget state) and index
  diff, insert/remove/update via insert_component/remove_component, per-index
  bindings.
- LayoutManager: list_bindings field, registration in apply_layout,
  on_list_data_changed, set_children_order.

Integration (nemo):
- parse_component_from_value reads list_binding into LayoutNode.
- apply_pending_data_updates calls on_list_data_changed after scalar bindings.
- render_component skips components with visible == false (generalizes Panel).
- validate: skip n:-prefixed attrs; list_binding treated as structural.

Example (examples/sfc): three new cards demonstrating static n:for, n:if, and
live-data n:for (add/remove/reverse with n:key), plus on-load seed + handlers.

Docs: mark Phases 1-3 implemented in the plan.
Parse_sfc gains handling for <app>/<data>/<imports>/<variable>/<themes>
top-level blocks, delegating to the same process_* functions process_root
uses. SfcDefinition gains app_blocks: Option<AppBlocks> carrying the
processed Values. New XmlParser::compile_app_sfc() assembles the same
Value tree process_root produces, mapping <template> to the layout key
(wrapped as {type, component: {root_id: template}}) and folding the
raw-text <script> body into scripts.inline.

Also trims CDATA inline script bodies in process_script to match the
raw-text splitter's trimming, so app.nemo and equivalent app.xml produce
identical Value trees.

Verified: round-trip equality tests covering <app>, <data>, <variable>,
<imports>, <template>, <script> (both src-attribute and raw-text forms).
Update plan status from planned to implemented, mark the Phase 1
verification bullet as done, update the plans index entry, and add a
knowledgebase log entry for the implementation.
Phases 2-3 of the app.nemo SFC entry plan. The manifest default entry
is now app.nemo (app.xml still works when set explicitly), and
ConfigurationLoader::load detects a .nemo extension and compiles the
SFC to the same Value tree load_xml_string produces, then runs the
same directive-compile + ${} resolution. Centralizing the dispatch in
loader.load means build, validate, schema, and load_config all accept
.nemo entries with no per-caller change.

- manifest.rs: default_entry() -> "app.nemo"
- loader.rs: load() dispatches .nemo to new load_nemo_string; extract
  shared parser_for + compile_resolve helpers
- build.rs: build_project works unchanged via loader.load; add
  project_build_nemo_entry_round_trips_via_dist test, make existing
  round-trip test use explicit entry = "app.xml"
- runtime.rs: load_config works unchanged; add
  test_runtime_load_config_accepts_nemo_entry
- main.rs: dist_flag test uses explicit entry = "app.xml"
- docs: mark Phases 2-3 implemented
Phase 4 — Dev/watch recompiles .nemo on save:
- path_is_watchable now matches nemo alongside xml/rhai/toml; the plan's
  claim that it already accepted .nemo was wrong (it only matched
  xml|rhai|toml, so .nemo saves were silently ignored).
- dev.rs error message mentions app.nemo.
- reload_config -> create_runtime -> load_config already compiles .nemo via
  Phase 3, so the watcher admission was the only code change needed.

Phase 5 — Validate/schema accept .nemo:
- validate.rs and schema.rs already call ConfigurationLoader::load(path),
  which Phase 3 wired to compile .nemo via load_nemo_string. No new
  detection code needed. The n:-prefixed-attribute skip was already present.

Add examples/sfc/app.nemo fixture (SFC entry mirroring app.xml, exercising
SFC composition and n:for/n:if directives).

Verified: nemo validate app.nemo --strict passes; nemo schema --app-config
app.nemo includes SFC descriptors; nemo build app.nemo compiles to dist/;
legacy app.xml still validates; compile+resolve ~35ms (well under 0.7s
hot-reload target); full test suite green.
Phase 6 — Settings persistence (overrides.xml overlay):
- New decision doc: settings-overrides-xml.md. The settings UI writes theme
  choices to overrides.xml next to the entry (app.nemo or app.xml), not into
  the entry file itself. xml_edit::set_app_theme now targets overrides.xml
  (creating/updating it); set_theme_in_xml synthesizes a fresh overlay when
  the content is empty/has no <app>. The entry file is never mutated.
- NemoRuntime::load_config merges the overlay's app key over the entry's
  (shallow merge) after loading. Only the runtime applies the overlay —
  build/validate/schema operate on the source entry so dist stays faithful.
  Works for both .nemo and .xml entries.

Phase 7 — Templates, examples, docs migration:
- Templates: basic, calculator, data-binding renamed app.xml → app.nemo
  (restructured as SFC with app-level blocks); nemo.toml added to each.
  new.rs tfile paths, render_readme, and Next-steps message updated. The
  complete template stays app.xml (uses multi-file <include>, which the SFC
  app-block path doesn't yet handle — deliberate scope limit).
- project_loader CONFIG_CANDIDATES: [app.nemo, app.xml, .nemo/app.nemo,
  .nemo/app.xml] — .nemo takes precedence.
- dev_panel auto-load: prefers app.nemo, falls back to app.xml.
- Examples: app.nemo fixtures added to examples/basic and examples/calculator.
- Docs: configuration.md (entry-file + settings-overlay sections),
  architecture.md, nemo-xml-reference skill, README.md updated. Plan
  phases 6-7 marked implemented.

Verified: nemo new --template basic scaffolds app.nemo + nemo.toml and
validates --strict; settings overlay overrides theme at load (unit tests);
legacy app.xml still validates; full suite (255+18+115) green; clippy + fmt clean.
@geoffjay
geoffjay merged commit b6e0ddc into main Aug 6, 2026
@geoffjay
geoffjay deleted the sfc-app branch August 6, 2026 22:08
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.

1 participant