Skip to content

Store watches and bindings as element-table components - #53

Merged
nixonyh merged 5 commits into
mainfrom
nixon/everything-table
Jun 17, 2026
Merged

Store watches and bindings as element-table components#53
nixonyh merged 5 commits into
mainfrom
nixon/everything-table

Conversation

@nixonyh

@nixonyh nixonyh commented Jun 17, 2026

Copy link
Copy Markdown
Member

Move per-element reactivity out of standalone stores and into the element table, keyed by ElementId, and reorganize it under one module.

Storage:

  • Add TypeTable::insert_by_column and an "arbitrary components" API on ElementTable (insert/remove/get/get_mut/components) backing the at-most-one-per-element data.
  • Watch and Binding are now per-element components in ElementTable.

Reorganize + rename:

  • Group both flavors under a reactive module (reactive::watch, reactive::binding) sharing one ChangedFn.
  • Rename the subtree-rebuild concept from reactive to watch: Watch/WatchElement, ctx.watch().

nixonyh added 3 commits June 17, 2026 18:31
Move per-element reactivity out of standalone stores and into the
element table, keyed by ElementId, and reorganize it under one module.

Storage:
- Add TypeTable::insert_by_column and an "arbitrary components" API on
  ElementTable (insert/remove/get/get_mut/components) backing the
  at-most-one-per-element data.
- Watch and Binding are now per-element components: their ElementId is
  the column key, so they drop the stored element_id field, the
  SparseMap stores, the reverse-index maps, and the explicit
  remove_for_element cleanup (handled by remove_row).
- This requires W: 'static on the build-time and update APIs.

Reorganize + rename:
- Group both flavors under a `reactive` module (reactive::watch,
  reactive::binding) sharing one ChangedFn.
- Rename the subtree-rebuild concept from `reactive` to `watch`:
  Watch/WatchElement, ctx.watch(), Fynix::update_watches.
- Encapsulate each flush step on its type: Watch::build does the whole
  rebuild and Binding::build does the field write + mark_dirty, leaving
  update_watches a thin snapshot-and-loop.
The per-frame flush already reconciles more than one kind of
change-driven update and will grow to cover more, so name it for what
it does rather than one kind: sync the element tree to the world.
@coderabbitai

coderabbitai Bot commented Jun 17, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@nixonyh, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 44 minutes and 43 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits.

🚦 How do rate limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan refill rate.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, the refill rate gradually slows as usage increases. The highest same-day bursts are limited more strictly.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: e6acd013-d8c5-4857-91d6-24a38a9d0344

📥 Commits

Reviewing files that changed from the base of the PR and between 0143527 and 5113e74.

📒 Files selected for processing (2)
  • crates/fynix/src/reactive/binding.rs
  • examples/vello_winit_examples/src/lib.rs
📝 Walkthrough

Walkthrough

Removes the old top-level binding.rs module and the reactives/bindings fields from Fynix<W>. Introduces reactive/binding.rs (Binding<W>) and reactive/watch.rs (Watch<W>, WatchElement) as ECS-style per-element components stored in ElementTable. Adds a generic component API to ElementTable and a TypeTable::insert_by_column primitive. Replaces FynixCtx::reactive with watch, updates ElementCtx::bind, and replaces Fynix::update_reactives with Fynix::sync.

Changes

Watch/Binding ECS Refactor

Layer / File(s) Summary
TypeTable column-targeted insertion
crates/typarena/src/type_table.rs
Adds insert_by_column that inserts a value into a pre-resolved ColumnId column, returning None on type mismatch or out-of-bounds.
ElementTable generic component API
crates/fynix/src/element/table.rs, crates/fynix/src/element/storage.rs
Migrates init_element/set_scene to use insert_by_column, adds insert_component, remove_component, get_component, get_component_mut, and components methods, with a round-trip unit test.
reactive module restructure
crates/fynix/src/reactive.rs
Strips old content to a header with ChangedFn<W> type alias and declares pub mod binding and pub mod watch.
Binding<W> component with type-erased field write
crates/fynix/src/reactive/binding.rs, crates/fynix/src/binding.rs
Adds Binding<W> storing a change-detector plus GetFnPtr/ApplyFn for type-erased world reads and element field writes; removes the old top-level binding.rs.
WatchElement and Watch<W> subtree rebuild
crates/fynix/src/reactive/watch.rs
Introduces WatchElement pass-through holder and Watch<W> with rebuild that removes the old child, re-runs build_fn under a captured style scope via FynixCtx, re-parents the new child, and marks the holder dirty. Includes conditional-rebuild unit test.
Fynix<W> struct and sync entry point
crates/fynix/src/lib.rs
Removes reactives/bindings fields (replaced by PhantomData), removes update_reactives, adds sync that iterates changed Watch<W> and Binding<W> components and calls their rebuild/build methods.
FynixCtx::watch and ElementCtx::bind API update
crates/fynix/src/ctx.rs
Replaces FynixCtx::reactive with watch (registers Watch<W> via insert_component), updates ElementCtx::bind to use top-level ChangedFn<W> and insert_component for Binding<W>.
Example render loop update
examples/vello_winit_examples/src/lib.rs
Replaces fynix.update_reactives(...) call with fynix.sync(...) in the render loop.

Sequence Diagram(s)

sequenceDiagram
  participant App
  participant World
  participant Fynix
  participant Elements
  participant WatchComp
  participant BindComp

  App->>World: update(dt)
  App->>Fynix: sync(world)
  Fynix->>Elements: iterate watch components
  Fynix->>WatchComp: check changed
  WatchComp-->>Fynix: changed ids
  Fynix->>WatchComp: rebuild subtree for each id
  WatchComp->>Elements: replace child and mark dirty
  Fynix->>Elements: iterate binding components
  Fynix->>BindComp: check changed
  BindComp-->>Fynix: changed ids
  Fynix->>BindComp: build field update for each id
  BindComp->>Elements: write field and mark dirty
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • voxell-tech/fynix#52: Introduced the original Binding<W>/GetFnPtr/ChangedFn concept in the old binding.rs that this PR replaces with the new reactive/binding.rs implementation.
  • voxell-tech/fynix#41: Added reactives storage and the update_reactives rebuild flow in lib.rs that this PR removes and replaces with sync.
  • voxell-tech/fynix#34: Introduced TypeTable/ColumnId APIs that this PR extends with insert_by_column and the new ElementTable component system.

Suggested reviewers

  • Sheerwin02
  • ian-hon

🐇 A rabbit once watched a field of code,
Where reactives and bindings made a heavy load.
Now Watch and Binding hop into their place,
As ECS components with elegant grace!
fynix.sync() calls them, one by one they run—
The world stays fresh, and the layout's done! 🌿

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and concisely summarizes the main architectural change: moving watches and bindings from standalone stores into ElementTable components.
Description check ✅ Passed The description is directly related to the changeset, clearly explaining the storage reorganization, module restructuring, and naming changes across all modified files.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (2)
examples/vello_winit_examples/src/lib.rs (1)

32-34: 💤 Low value

Consider mentioning bindings alongside watches for completeness.

The doc comment only mentions watches, but Fynix::sync also flushes bindings. For consistency with the inline comment at lines 110-111 (which correctly mentions both), consider updating to "Advances world state before watches and bindings are updated."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/vello_winit_examples/src/lib.rs` around lines 32 - 34, The doc
comment for the update method currently only mentions "watches are updated" but
the implementation also flushes bindings as evidenced by the inline comment at
lines 110-111. Update the doc comment to state "Advances world state before
watches and bindings are updated" to accurately reflect what the update method
does and maintain consistency with the inline documentation elsewhere in the
code.
crates/fynix/src/reactive/binding.rs (1)

80-89: ⚡ Quick win

Reduce public unsafe surface for erased-pointer internals.

ApplyFn and GetFnPtr look like internal Binding plumbing. Exposing them publicly (plus typed_unchecked) broadens downstream UB footguns without clear API value.

♻️ Proposed visibility tightening.
-pub type ApplyFn<W> = fn(
+pub(crate) type ApplyFn<W> = fn(
     world: &W,
     elements: &mut Elements,
     id: &ElementId,
     get_fn: GetFnPtr,
     get_mut: MutFnPtr,
 );
@@
-pub struct GetFnPtr(*const ());
+pub(crate) struct GetFnPtr(*const ());
@@
-    pub const unsafe fn typed_unchecked<S, T>(&self) -> GetFn<S, T> {
+    pub(crate) const unsafe fn typed_unchecked<S, T>(&self) -> GetFn<S, T> {
         unsafe {
             core::mem::transmute::<*const (), GetFn<S, T>>(self.0)
         }
     }

Also applies to: 113-137

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/fynix/src/reactive/binding.rs` around lines 80 - 89, The ApplyFn type
alias and other internal erased-pointer types used in Binding's implementation
are currently publicly exposed, which unnecessarily broadens the unsafe surface
area without providing clear API value. Remove the pub visibility modifier from
ApplyFn to make it private, and apply the same visibility tightening to GetFnPtr
and the typed_unchecked function mentioned in the range 113-137. These should be
internal implementation details only, not part of the public API.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/fynix/src/element/table.rs`:
- Around line 107-146: The public component API methods insert_component,
remove_component, get_component, get_component_mut, and components allow callers
to access and modify reserved metadata types like ElementNode, Scene, and
StyleId through the same TypeTable namespace, which breaks ElementTable
invariants. Add a trait bound constraint (such as a marker trait that reserved
types do not implement) to the generic type parameter T in all these methods to
prevent reserved types from being used while allowing user-defined component
types. This ensures that only safe, non-reserved types can be managed through
the public component API.

In `@crates/fynix/src/reactive/watch.rs`:
- Around line 115-125: The code stores a child_id in elem.child even when the
node lookup via node_mut(&child_id) fails, leaving a dangling reference. Move
the elem.child = child assignment inside the first if block that checks both
child_id existence and successful node retrieval via
fynix.elements.table.node_mut(&child_id), so that WatchElement.child only stores
valid child references that exist in the node table.

---

Nitpick comments:
In `@crates/fynix/src/reactive/binding.rs`:
- Around line 80-89: The ApplyFn type alias and other internal erased-pointer
types used in Binding's implementation are currently publicly exposed, which
unnecessarily broadens the unsafe surface area without providing clear API
value. Remove the pub visibility modifier from ApplyFn to make it private, and
apply the same visibility tightening to GetFnPtr and the typed_unchecked
function mentioned in the range 113-137. These should be internal implementation
details only, not part of the public API.

In `@examples/vello_winit_examples/src/lib.rs`:
- Around line 32-34: The doc comment for the update method currently only
mentions "watches are updated" but the implementation also flushes bindings as
evidenced by the inline comment at lines 110-111. Update the doc comment to
state "Advances world state before watches and bindings are updated" to
accurately reflect what the update method does and maintain consistency with the
inline documentation elsewhere in the code.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 0322b4dd-32c3-4f08-ad16-8032b23b66a1

📥 Commits

Reviewing files that changed from the base of the PR and between 2a410b7 and 0143527.

📒 Files selected for processing (10)
  • crates/fynix/src/binding.rs
  • crates/fynix/src/ctx.rs
  • crates/fynix/src/element/storage.rs
  • crates/fynix/src/element/table.rs
  • crates/fynix/src/lib.rs
  • crates/fynix/src/reactive.rs
  • crates/fynix/src/reactive/binding.rs
  • crates/fynix/src/reactive/watch.rs
  • crates/typarena/src/type_table.rs
  • examples/vello_winit_examples/src/lib.rs
💤 Files with no reviewable changes (1)
  • crates/fynix/src/binding.rs

Comment thread crates/fynix/src/element/table.rs
Comment on lines +115 to +125
if let Some(child_id) = child
&& let Some(node) =
fynix.elements.table.node_mut(&child_id)
{
node.parent_id = Some(id);
}
if let Some(elem) =
fynix.elements.get_typed_mut::<WatchElement>(&id)
{
elem.child = child;
}

@coderabbitai coderabbitai Bot Jun 17, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Keep WatchElement.child consistent with node storage.

If build_fn returns Some(child_id) but node_mut(&child_id) is None, the code still stores Some(child_id) in elem.child. That leaves a dangling child reference and can break later layout on that holder.

🐛 Suggested guard to drop invalid child ids.
-        if let Some(child_id) = child
-            && let Some(node) =
-                fynix.elements.table.node_mut(&child_id)
-        {
-            node.parent_id = Some(id);
-        }
+        let child = if let Some(child_id) = child {
+            if let Some(node) = fynix.elements.table.node_mut(&child_id)
+            {
+                node.parent_id = Some(id);
+                Some(child_id)
+            } else {
+                None
+            }
+        } else {
+            None
+        };
         if let Some(elem) =
             fynix.elements.get_typed_mut::<WatchElement>(&id)
         {
             elem.child = child;
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if let Some(child_id) = child
&& let Some(node) =
fynix.elements.table.node_mut(&child_id)
{
node.parent_id = Some(id);
}
if let Some(elem) =
fynix.elements.get_typed_mut::<WatchElement>(&id)
{
elem.child = child;
}
let child = if let Some(child_id) = child {
if let Some(node) = fynix.elements.table.node_mut(&child_id)
{
node.parent_id = Some(id);
Some(child_id)
} else {
None
}
} else {
None
};
if let Some(elem) =
fynix.elements.get_typed_mut::<WatchElement>(&id)
{
elem.child = child;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/fynix/src/reactive/watch.rs` around lines 115 - 125, The code stores a
child_id in elem.child even when the node lookup via node_mut(&child_id) fails,
leaving a dangling reference. Move the elem.child = child assignment inside the
first if block that checks both child_id existence and successful node retrieval
via fynix.elements.table.node_mut(&child_id), so that WatchElement.child only
stores valid child references that exist in the node table.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Leaving this unresolved, a good concern, but not critical. We might need more debug_assert!.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@nixonyh, that's a reasonable call. A debug_assert! would be a good fit here - it documents the invariant and catches violations in debug builds without changing the release-build control flow. Something like:

if let Some(child_id) = child
    && let Some(node) = fynix.elements.table.node_mut(&child_id)
{
    node.parent_id = Some(id);
} else if child.is_some() {
    debug_assert!(false, "build_fn returned a child_id with no corresponding node");
}

Or more concisely at the else branch:

debug_assert!(
    child.is_none() || fynix.elements.table.node_mut(child.as_ref().unwrap()).is_some(),
    "build_fn returned a child_id with no corresponding node in the element table"
);

Would you like me to open a follow-up GitHub issue to track adding the debug_assert! guard here?


🧠 Learnings used
Learnt from: nixonyh
Repo: voxell-tech/fynix PR: 32
File: crates/fynix/src/lib.rs:33-39
Timestamp: 2026-05-21T09:23:49.477Z
Learning: In this Rust codebase, `use` import granularity should be at the module level. When importing multiple items from the same module path, group them into a single `use` statement using curly braces (e.g., `use crate::element::{Element, ElementBuild, ...};`). Do not recommend splitting grouped imports into separate lines (including one `pub use` per type) when they originate from the same module.

Learnt from: nixonyh
Repo: voxell-tech/fynix PR: 33
File: examples/vello_winit_examples/src/lib.rs:6-6
Timestamp: 2026-05-30T14:15:01.696Z
Learning: When reviewing Rust code in this repo, do not flag grouped `use` statements as style violations if they match the formatting configured in `rustfmt.toml` (imports_granularity = "Module" and group_imports = "StdExternalCrate"). Grouped imports such as `use fynix::{Fynix, rectree};` and `use vello::{AaConfig, RenderParams, Renderer, RendererOptions, Scene, wgpu};` are intentional and will be auto-enforced by the nightly `rustfmt` settings, so they should be treated as compliant formatting.

@nixonyh
nixonyh merged commit 660e860 into main Jun 17, 2026
8 checks passed
@nixonyh
nixonyh deleted the nixon/everything-table branch June 17, 2026 12:15
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