Enhance audit documentation and address critical Ref property issues

- Updated the audit guide to clarify the workflow and prerequisites for conducting a production-grade sync feature audit.
- Introduced a new plan file detailing fixes for critical issues identified in the two-way sync audit, including missing instance maps and stale paths after renames.
- Enhanced `ServeSession.lua` to correctly pass the instance map in patch updates, ensuring accurate handling of unresolved Ref properties.
- Implemented various fixes in `change_processor.rs` and `rojo_ref.rs` to address critical bugs related to Ref path handling and instance renaming.
- Added tests to verify the correct behavior of Ref properties and ensure robustness against edge cases.

These changes improve the reliability of the synchronization system and enhance the clarity of the audit process.
This commit is contained in:
Joe 2026-02-14 12:53:28 -06:00
parent 8f0df692d3
commit ca2a938902
8 changed files with 811 additions and 90 deletions

View file

@ -1,32 +1,40 @@
# /audit - Production-Grade Sync Feature Audit # /audit - Production-Grade Sync Feature Audit
Perform a systematic, production-grade audit of a sync feature implementation. This command is specifically designed for Atlas's bidirectional synchronization system (filesystem <-> Roblox Studio). **Mode:** This command runs in **Plan mode** (read-only). It does NOT directly apply code changes. The deliverable is a `.cursor/plans/*.plan.md` file containing every approved fix, ready to be executed in a subsequent Agent-mode session.
## The Invariant **Workflow:** Analyze -> Report -> Quiz user on each fix -> Write plan file.
Every audit item MUST be evaluated against this standard: ## Prerequisites
> **Round-trip identity:** Two-way sync (or syncback) writes a directory tree. Building an rbxl from that directory tree and forward-syncing it back must produce a **bit-identical instance tree** -- same names, same classes, same properties, same hierarchy, same ref targets. Any deviation is a bug. Before beginning the audit, read `.cursor/rules/atlas.mdc` for the project's quality standards, architecture overview, and code conventions. The two standards below are extracted from that file and govern every audit finding:
### The Invariant (from `atlas.mdc` §Quality Standard)
> **Round-trip identity:** Syncback (or two-way sync) writes a directory tree. Building an rbxl from that directory tree and forward-syncing it back must produce a bit-identical instance tree -- same names, same classes, same properties, same hierarchy, same ref targets. Any deviation is a bug.
A "works most of the time" finding is NOT acceptable. If there is ANY code path where an instance name, property, or hierarchy relationship can be lost, mangled, or silently altered through a syncback/two-way-sync -> rebuild cycle, flag it as **critical**. A "works most of the time" finding is NOT acceptable. If there is ANY code path where an instance name, property, or hierarchy relationship can be lost, mangled, or silently altered through a syncback/two-way-sync -> rebuild cycle, flag it as **critical**.
## CLI Syncback Parity ### CLI Syncback Parity (from `atlas.mdc` §Two-Way Sync Strategy)
From `atlas.mdc`: "Plugin-based sync must produce **exactly the same filesystem output** that `atlas syncback` would give for the same input. Byte-for-byte identical files, identical directory structures, identical naming." > Plugin-based sync must produce **exactly the same filesystem output** that `atlas syncback` would give for the same input. Byte-for-byte identical files, identical directory structures, identical naming.
Any divergence between the two-way sync path and the CLI syncback path is a bug until proven otherwise. The CLI syncback is the ground truth. Any divergence between the two-way sync path and the CLI syncback path is a bug until proven otherwise. The CLI syncback is the ground truth.
## Code Quality ### Code Quality (from `atlas.mdc` §Code Quality Standard)
Watch for duplicated logic -- the same pattern copy-pasted across `change_processor.rs`, `api.rs`, `dir.rs`, `project.rs`, etc. If the same sequence appears in 3+ places, that's a refactor candidate. > Watch for duplicated logic -- the same slugify/dedup/meta-update pattern copy-pasted across `change_processor.rs`, `api.rs`, `dir.rs`, `project.rs`, etc. The goal is clean, DRY code that is easy to reason about and hard to get wrong.
- **Small refactor** (helper function, 2-3 call sites): flag it AND fix it. - **Small refactor** (helper function, 2-3 call sites): flag it and include it in the fix plan.
- **Major rewrite** (10+ call sites, pipeline redesign): flag it in a "Deferred Refactors" section. - **Major rewrite** (10+ call sites, pipeline redesign): flag it in a "Deferred Refactors" section of the plan.
--- ---
## Instructions ## Instructions
### 0. Read Project Standards
Read `.cursor/rules/atlas.mdc` in full. This file defines architecture, conventions, and quality bars that inform every audit step. Do not proceed without reading it.
### 1. Identify the Feature Scope ### 1. Identify the Feature Scope
**Default behavior:** Audit all changes in the current workspace against `origin/master`. This captures everything: committed branch changes, unpushed commits, staged changes, and unstaged changes. Run: **Default behavior:** Audit all changes in the current workspace against `origin/master`. This captures everything: committed branch changes, unpushed commits, staged changes, and unstaged changes. Run:
@ -257,46 +265,18 @@ Focus on warnings in files modified by the feature. Clippy catches: unused varia
Also run `selene plugin/src` for Lua linting if plugin files were modified. Also run `selene plugin/src` for Lua linting if plugin files were modified.
### 13. Write Tests for Every Finding ### 13. Produce the Report
Tests are not optional. Every issue found during the audit must have test coverage. This applies to ALL test layers: Structured report with:
- **Rust unit tests** (`#[cfg(test)]` blocks) -- for isolated logic (helpers, path computation, patch compute/apply) - **Critical issues** -- data loss, incorrect values, silent corruption on round-trip
- **Rust integration tests** (`tests/tests/`) -- for end-to-end server behavior (serve tests, two-way sync via `/api/write`, connected mode, syncback) - **Correctness concerns** -- edge cases that might not manifest immediately
- **Lua spec tests** (`.spec.lua` files) -- for plugin-side encoding, decoding, batching, reconciliation - **Missing test coverage** -- specific test cases needed, prioritized by risk
- **Snapshot tests** (`insta` crate) -- for forward-sync patch output verification - **Known limitations** -- for each limitation: what it is, the concrete scenario, severity, and what fixing it would require
- **Challenged limitations** -- for each limitation: what it is, user's decision (accept/fix), and if accepted, confirmation that warnings are logged and no silent corruption occurs beyond the accepted scope
#### 13a. Tests for bugs found and fixed - **Code quality items** -- DRY violations, dead code, fragile error paths
- **Deferred refactors** -- major structural improvements too large to do inline
Every bug fixed during the audit MUST have a test that: - For each issue: **file path, line numbers, description, and suggested fix**
1. Would have FAILED before the fix
2. PASSES after the fix
3. Prevents the bug from regressing
#### 13b. Tests for missing coverage identified
For each "missing test coverage" gap identified in the audit:
1. Write the test
2. Verify it passes against the current implementation
3. If it fails, investigate -- it may have found another bug
#### 13c. Tests for known limitations
For each known limitation that violates round-trip identity:
1. Write a test that asserts the **correct** behavior
2. The test SHOULD FAIL against the current implementation
3. **Leave the test failing** -- do NOT mark it `#[ignore]` or fix the implementation
4. If a test unexpectedly PASSES, analyze why. If correct, keep it as free coverage
5. These tests serve as acceptance criteria for future fixes
#### 13d. Scope: test every affected layer
For each finding, determine which test layers are affected and write tests in ALL of them:
- Does the bug manifest at the plugin level? -> Lua spec test
- Does it manifest at the server level? -> Rust unit test or integration test
- Does it affect the round-trip? -> Integration test with `/api/write` + filesystem verification
- Does it affect forward-sync? -> Serve test with `recv_socket_packet`
- Could it regress from a different code path? -> Test that code path too
### 14. Challenge Known Limitations ### 14. Challenge Known Limitations
@ -310,14 +290,180 @@ Do NOT blindly accept "known limitations" from implementation plans. For every l
2. **Ask the user to explicitly confirm** it's acceptable for this release, or whether it should be fixed now. 2. **Ask the user to explicitly confirm** it's acceptable for this release, or whether it should be fixed now.
3. **Never silently skip a round-trip violation** just because a plan document says "known limitation." Plans are written before implementation -- the user may have changed their mind, or the severity may be worse than anticipated. 3. **Never silently skip a round-trip violation** just because a plan document says "known limitation." Plans are written before implementation -- the user may have changed their mind, or the severity may be worse than anticipated.
### 15. Produce the Report ### 15. Quiz User on Each Planned Fix
Structured report with: **No code changes may be applied until the user explicitly approves each one.** After producing the report and challenging limitations, present EVERY planned fix to the user for approval before writing any code.
- **Critical issues** -- data loss, incorrect values, silent corruption on round-trip For each fix identified in the report (bug fixes, refactors, test additions, etc.), use the **AskQuestion tool** to present:
- **Correctness concerns** -- edge cases that might not manifest immediately
- **Missing test coverage** -- specific test cases needed, prioritized by risk 1. **What the fix changes** -- which file(s), what code, what behavior changes
- **Challenged limitations** -- for each limitation: what it is, user's decision (accept/fix), and if accepted, confirmation that warnings are logged and no silent corruption occurs beyond the accepted scope 2. **Why it's needed** -- which audit finding it addresses (reference the report section)
- **Code quality items** -- DRY violations, dead code, fragile error paths 3. **Risk assessment** -- could this fix break anything else? What's the blast radius?
- **Deferred refactors** -- major structural improvements too large to do inline 4. **Options:**
- For each issue: **file path, line numbers, description, and suggested fix** - **Apply** -- proceed with this fix
- **Skip** -- do not apply this fix (document the reason)
- **Modify** -- the user wants a different approach (wait for their input before proceeding)
**Rules:**
- Present fixes **one at a time** so the user can evaluate each independently
- Group closely related fixes (e.g., a bug fix + its regression test) into a single question, but never bundle unrelated fixes
- If the user selects "Modify," wait for their revised instructions before continuing to the next fix
- After all fixes are quizzed, summarize which were approved, skipped, and modified
- Only proceed to step 16 with the approved and modified fixes
### 16. Create the Fix Plan
After all fixes have been quizzed and the user has made their decisions, write a plan file to `.cursor/plans/`. This plan is the deliverable of the audit -- do NOT directly apply any code changes.
**Filename:** `.cursor/plans/<descriptive_slug>.plan.md` (e.g., `fix_ref_round_trip_issues.plan.md`)
**Plan structure:**
```markdown
# <Title describing the audit scope>
> This plan was generated by `/audit` from a Plan-mode session.
> Refer to `.cursor/rules/atlas.mdc` for full project standards.
## Standards
These standards govern every fix in this plan. An implementer MUST read `.cursor/rules/atlas.mdc` before starting.
### Round-Trip Identity (from `atlas.mdc` §Quality Standard)
Syncback (or two-way sync) writes a directory tree. Building an rbxl from that directory tree and forward-syncing it back must produce a **bit-identical instance tree** -- same names, same classes, same properties, same hierarchy, same ref targets. Any deviation is a bug.
### CLI Syncback Parity (from `atlas.mdc` §Two-Way Sync Strategy)
Plugin-based sync must produce **exactly the same filesystem output** that `atlas syncback` would give for the same input. Byte-for-byte identical files, identical directory structures, identical naming. Any divergence is a bug until proven otherwise.
### Code Quality (from `atlas.mdc` §Code Quality Standard)
DRY code that is easy to reason about and hard to get wrong. The same slugify/dedup/meta-update pattern should not be copy-pasted across files. Small refactors (2-3 call sites) are included as fixes. Major rewrites (10+ call sites) are deferred.
## Context
<Brief summary of what was audited, which branch/feature, and the audit findings.>
## Fixes
Each fix below was approved by the user during the audit quiz (step 15). Implement them in order. Each fix includes its test requirements -- a fix is not complete until its tests pass.
### Fix N: <Short title>
- **Status:** Approved | Modified
- **Finding:** <Which audit section identified this (e.g., "Step 2a: Round-trip identity")>
- **Files:** <List of files to modify>
- **Problem:** <What's wrong, with file paths and line numbers>
- **Solution:** <Exact description of what to change -- specific enough to implement without re-reading the audit. For modified fixes, this reflects the user's revised approach.>
- **Risk:** <Blast radius, what else could break>
- **Verify round-trip:** <How to confirm this fix preserves round-trip identity -- specific test scenario>
- **Verify syncback parity:** <How to confirm two-way sync output matches CLI syncback -- or "N/A" if this fix doesn't touch sync paths>
- **Tests required:**
- <Specific test description, which test layer (unit/integration/spec/snapshot), expected behavior>
## Skipped Fixes
Documented so they aren't lost -- they may be revisited later.
### Skipped: <Short title>
- **Finding:** <Audit section>
- **Problem:** <What's wrong>
- **Reason skipped:** <User's stated reason>
## Accepted Limitations
For each limitation the user accepted in step 14:
- **Limitation:** <What it is>
- **Scenario:** <When data is lost/corrupted>
- **User decision:** Accepted for this release
- **Mitigation:** <Any warnings/logging that should be in place>
## Deferred Refactors
Major structural improvements flagged during the audit that are too large to include. These should be evaluated after the fixes above are complete.
- <Refactor description, affected files, estimated scope>
## Test Plan
Tests are not optional. Every approved fix must have test coverage. A fix is not complete until its tests pass (or intentionally fail, for known limitations).
Summary of ALL tests to be written across every fix, organized by layer. Each entry references back to the fix that requires it.
### Rust Unit Tests (`#[cfg(test)]` blocks)
For isolated logic: helpers, path computation, patch compute/apply.
- <test description> (Fix N)
### Rust Integration Tests (`tests/tests/`)
For end-to-end server behavior: serve tests, two-way sync via `/api/write`, connected mode, syncback.
- <test description> (Fix N)
### Lua Spec Tests (`.spec.lua` files)
For plugin-side encoding, decoding, batching, reconciliation.
- <test description> (Fix N)
### Snapshot Tests (`insta` crate)
For forward-sync patch output verification.
- <test description> (Fix N)
### Failing Tests for Known Limitations
- <test description -- these assert correct behavior and are expected to fail> (Accepted Limitation)
## Test Rules
The implementer MUST follow these rules when writing tests for this plan:
### For bugs found and fixed
Every bug fix MUST have a test that:
1. Would have **FAILED** before the fix
2. **PASSES** after the fix
3. Prevents the bug from regressing
### For missing coverage identified
For each "missing test coverage" gap:
1. Write the test
2. Verify it passes against the current implementation
3. If it fails, investigate -- it may have found another bug
### For known limitations (Accepted Limitations section)
For each known limitation that violates round-trip identity:
1. Write a test that asserts the **correct** behavior
2. The test SHOULD FAIL against the current implementation
3. **Leave the test failing** -- do NOT mark it `#[ignore]` or fix the implementation
4. If a test unexpectedly PASSES, analyze why. If correct, keep it as free coverage
5. These tests serve as acceptance criteria for future fixes
### Scope: test every affected layer
For each finding, determine which test layers are affected and write tests in ALL of them:
- Does the bug manifest at the plugin level? -> Lua spec test
- Does it manifest at the server level? -> Rust unit test or integration test
- Does it affect the round-trip? -> Integration test with `/api/write` + filesystem verification
- Does it affect forward-sync? -> Serve test with `recv_socket_packet`
- Could it regress from a different code path? -> Test that code path too
## Final Step: Run CI
After ALL fixes and tests are implemented, run the `/ci` command (see `.cursor/commands/ci.md`) to execute the full CI pipeline. Every fix must pass CI before the plan is considered complete. Do not skip this step.
```
### 17. Run CI After Plan Execution
After the plan has been fully executed in Agent mode (all approved fixes applied, all tests written), run the `/ci` command (`.cursor/commands/ci.md`) as the final step. The plan is not complete until CI passes clean.
**Rules for the plan file:**
- The plan must be **self-contained**: an implementer should be able to execute it in Agent mode by reading only the plan file and `atlas.mdc`, without re-reading the audit chat
- The Standards section is mandatory -- it anchors every fix to the project's quality bars
- Every fix must include `Verify round-trip` and `Verify syncback parity` fields so the implementer knows how to validate correctness
- Include exact file paths and line numbers (as of the current commit)
- For each fix, list the tests that must accompany it -- a fix without a test entry is incomplete
- Tests for known limitations must assert the **correct** behavior (they are expected to fail against the current implementation and should be left failing)
- Skipped fixes are documented with their problem description so future audits don't rediscover them
- The plan file is the single source of truth for post-audit work; do not leave information only in the chat

View file

@ -0,0 +1,380 @@
---
name: Fix Ref Audit Issues
overview: "Fix all 4 critical issues and 2 correctness concerns from the Ref two-way sync audit: ServeSession missing instanceMap, stale paths after rename, instance names with / in paths, compute_ref_properties priority conflict, encodeInstance Ref leak, and filter_properties_for_meta implicit Ref handling."
todos:
- id: fix-serve-session
content: Fix ServeSession.lua:738 -- add self.__instanceMap as 4th arg to encodePatchUpdate, grep for other missing callers
status: completed
- id: fix-priority
content: Fix compute_ref_properties priority -- swap processing order so Rojo_Ref_* is processed after Rojo_Target_* (path-based wins)
status: completed
- id: fix-encode-instance
content: Fix encodeInstance.lua Ref leak -- add explicit Ref skip before encodeProperty call
status: completed
- id: fix-filter-explicit
content: Fix filter_properties_for_meta -- add explicit Variant::Ref skip alongside UniqueId
status: completed
- id: fix-slash-escape
content: Add escape/unescape helpers for / in instance names, update ref_target_path, get_instance_by_path, inst_path
status: completed
- id: fix-stale-paths
content: Add update_ref_paths_after_rename to ChangeProcessor -- scan tree for stale Rojo_Ref_* paths and update them after rename
status: completed
- id: fix-stale-meta
content: Add update_ref_paths_in_meta helper to rewrite Rojo_Ref_* attributes in meta/model files on disk
status: completed
- id: dry-target-helper
content: "DRY: Extract Rojo_Target_* attribute name formatting into shared helper in rojo_ref.rs (matches ref_attribute_name pattern)"
status: completed
- id: dry-binarystring
content: "DRY: Extract BinaryString -> UTF-8 attribute value parsing into shared helper, used by compute_ref_properties and defer_ref_properties"
status: completed
- id: test-parity
content: "Test: Add CLI syncback parity test -- verify two-way sync and CLI syncback produce identical Rojo_Ref_* output for same input"
status: cancelled
- id: test-concurrent
content: "Test: Add concurrent Ref changes test -- multiple Ref properties changed in same batch on same instance"
status: cancelled
- id: test-edge-cases
content: "Test: Add edge case tests -- Ref to ProjectNode instance, Ref to nested project instance, Ref property name not in reflection"
status: cancelled
- id: fix-ambiguous-warning
content: Add ambiguous path warning in syncback_updated_properties when target path has duplicate-named siblings at any ancestor level
status: completed
- id: verify-regression
content: Run regression tests and verify expected pass/fail outcomes match predictions
status: completed
isProject: false
---
# Fix All Ref Audit Issues
## Issue 1: ServeSession.lua:738 Missing instanceMap (CRITICAL)
**One-line fix.** [ServeSession.lua](plugin/src/ServeSession.lua) line 738:
```lua
-- Before:
local update = encodePatchUpdate(instance, change.id, propertiesToSync)
-- After:
local update = encodePatchUpdate(instance, change.id, propertiesToSync, self.__instanceMap)
```
Also grep for any OTHER callers of `encodePatchUpdate` that may be missing the 4th arg.
---
## Issue 2: Stale Rojo_Ref_* Paths After Rename (CRITICAL)
When an instance is renamed, all `Rojo_Ref_*` attributes across the tree that reference the old path must be updated to the new path.
### Design
When `handle_tree_event` processes a rename:
1. Compute the **old path** and **new path** of the renamed instance (using `full_path_of` before and after rename)
2. Scan ALL descendants of the tree root for `Rojo_Ref_*` attributes
3. For each attribute whose path **starts with** the old path (prefix match handles children too), replace the old prefix with the new prefix
4. Update the in-memory tree's Attributes property
5. Write the updated meta/model file to disk (with `suppress_path`)
### Implementation
**File:** [change_processor.rs](src/change_processor.rs)
Add a new method `update_ref_paths_after_rename(&self, tree, old_path, new_path)` that:
- Iterates `tree.descendants(root_id)`
- For each instance, checks `properties.get("Attributes")` for `Variant::Attributes`
- For each attribute starting with `REF_PATH_ATTRIBUTE_PREFIX`, checks if the path value starts with `old_path`
- If match: replaces the prefix with `new_path`
- Updates the instance's Attributes property in the tree
- Finds the instance's meta/model file via `InstigatingSource::Path` and rewrites the `Rojo_Ref_*` attribute on disk
Call this method from the rename handler, AFTER the filesystem rename is complete and the tree path has been updated. Both the directory-format rename path (line ~885) and the regular file rename path (line ~973) need it.
**Important:** The old path must be computed BEFORE the rename happens. Capture it early:
```rust
let old_path = tree.inner().full_path_of(instance_id, "/");
// ... do the rename ...
let new_path = tree.inner().full_path_of(instance_id, "/");
update_ref_paths_after_rename(&tree, &old_path, &new_path);
```
The prefix match handles all descendants: if `"Workspace/TestModel"` is renamed to `"Workspace/RenamedModel"`, then `"Workspace/TestModel/Part1"` becomes `"Workspace/RenamedModel/Part1"`.
### Meta file rewriting
Use the existing pattern from `upsert_meta_name_field`:
1. Read the JSON5 file
2. Parse as `serde_json::Value`
3. Navigate to `attributes` object
4. Update matching `Rojo_Ref_*` values
5. Write back with `suppress_path`
Extract a helper in [syncback/meta.rs](src/syncback/meta.rs) (or [rojo_ref.rs](src/rojo_ref.rs)):
```rust
pub fn update_ref_paths_in_meta(
meta_path: &Path,
old_prefix: &str,
new_prefix: &str,
) -> anyhow::Result<bool> // returns true if any attribute was updated
```
---
## Issue 3: Instance Names Containing "/" Corrupt Ref Paths (CRITICAL)
`full_path_of` joins names with "/" separator. `get_instance_by_path` splits on "/". If an instance name contains "/", the path becomes ambiguous.
### Design
Escape "/" in instance names when building paths. Unescape after splitting when resolving paths. Use `\/` as the escape sequence (backslash-slash).
### Implementation
**File:** [rojo_ref.rs](src/rojo_ref.rs)
Add escape/unescape helpers:
```rust
/// Escape "/" in an instance name for use in ref paths.
/// Uses "\/" as escape sequence.
pub fn escape_ref_path_segment(name: &str) -> Cow<'_, str> {
if name.contains('/') {
Cow::Owned(name.replace('/', "\\/"))
} else {
Cow::Borrowed(name)
}
}
/// Unescape "\/" back to "/" in a path segment.
pub fn unescape_ref_path_segment(segment: &str) -> Cow<'_, str> {
if segment.contains("\\/") {
Cow::Owned(segment.replace("\\/", "/"))
} else {
Cow::Borrowed(segment)
}
}
```
**File:** [rojo_ref.rs](src/rojo_ref.rs) -- Update `ref_target_path`:
Instead of calling `dom.full_path_of(target_ref, "/")`, build the path manually with escaping:
```rust
pub fn ref_target_path(dom: &WeakDom, target_ref: Ref) -> String {
let root_ref = dom.root_ref();
let mut components: Vec<String> = dom
.ancestors_of(target_ref)
.filter(|inst| inst.referent() != root_ref)
.map(|inst| escape_ref_path_segment(&inst.name).into_owned())
.collect();
components.reverse();
components.join("/")
}
```
**File:** [tree.rs](src/snapshot/tree.rs) -- Update `get_instance_by_path`:
Split on unescaped "/" only, then unescape each segment:
```rust
pub fn get_instance_by_path(&self, path: &str) -> Option<Ref> {
if path.is_empty() {
return Some(self.get_root_id());
}
// Split on "/" but not "\/"
let segments = split_ref_path(path);
// ... walk tree using unescaped segment names ...
}
```
Add a path splitting function to `rojo_ref.rs` that splits on unescaped "/" only:
```rust
pub fn split_ref_path(path: &str) -> Vec<String> {
// Split on "/" that is NOT preceded by "\"
// Then unescape each segment
}
```
**Also update:** `inst_path` in [syncback/snapshot.rs](src/syncback/snapshot.rs) to use the escaped path builder for CLI syncback consistency.
---
## Issue 4: compute_ref_properties Priority Conflict (CRITICAL)
If both `Rojo_Ref_PrimaryPart` and `Rojo_Target_PrimaryPart` exist, `Rojo_Target_*` silently overwrites `Rojo_Ref_*` because it's processed second.
### Fix
**File:** [patch_compute.rs](src/snapshot/patch_compute.rs) `compute_ref_properties`
Swap the processing order: process `Rojo_Target_*` FIRST, then `Rojo_Ref_*`. Since `map.insert` overwrites, the last insert wins. Processing `Rojo_Ref_*` second means the path-based system has priority (which is correct since it's the preferred system).
```rust
for (attr_name, attr_value) in attributes.iter() {
// Process legacy Rojo_Target_* FIRST (lower priority)
if let Some(prop_name) = attr_name.strip_prefix(REF_POINTER_ATTRIBUTE_PREFIX) {
// ... resolve via ID ...
map.insert(ustr(prop_name), ...);
continue;
}
// Process Rojo_Ref_* SECOND (higher priority, overwrites if both exist)
if let Some(prop_name) = attr_name.strip_prefix(REF_PATH_ATTRIBUTE_PREFIX) {
// ... resolve via path ...
map.insert(ustr(prop_name), ...);
continue;
}
}
```
---
## Issue 5: encodeInstance.lua Ref Leak (Correctness)
**File:** [encodeInstance.lua](plugin/src/ChangeBatcher/encodeInstance.lua)
Ref properties pass through `UNENCODABLE_DATA_TYPES` check and reach `encodeProperty` where they fail via pcall. Add an explicit skip:
```lua
-- After the UNENCODABLE_DATA_TYPES check, before encodeProperty:
if descriptor.dataType == "Ref" then
-- Ref properties cannot be encoded during instance addition because
-- the target instance has no server ID yet. They are handled separately
-- by encodePatchUpdate.lua during property update encoding.
continue
end
```
---
## Issue 6: filter_properties_for_meta Implicit Ref Handling (Correctness)
**File:** [api.rs](src/web/api.rs) `filter_properties_for_meta`
Add explicit skip with comment for Variant::Ref alongside the UniqueId skip:
```rust
// Skip UniqueId and Ref - they don't serialize to JSON.
// Ref properties are handled upstream in syncback_updated_properties()
// where they are converted to Rojo_Ref_* path-based attributes.
if matches!(value, Variant::UniqueId(_) | Variant::Ref(_)) {
continue;
}
```
---
## Issue 6b: Ambiguous Path Warning in Two-Way Sync
**File:** [api.rs](src/web/api.rs) `syncback_updated_properties()`
When computing a Ref target path, the two-way sync path does NOT check whether the path is ambiguous (duplicate-named siblings at any ancestor level). CLI syncback has `compute_refs_with_duplicate_siblings` for this. The two-way sync should at minimum log a warning.
After computing the path via `ref_target_path`, check for path ambiguity:
```rust
let path = crate::ref_target_path(tree.inner(), *target_ref);
// Warn if the path is ambiguous (duplicate-named siblings)
if !is_ref_path_unique(tree, *target_ref) {
log::warn!(
"Ref property '{}' for instance {:?} has an ambiguous path '{}' \
(duplicate-named siblings exist). The ref may resolve to the wrong \
target on rebuild.",
key, update.id, path
);
}
```
Add a helper `is_ref_path_unique(tree, target_ref)` that walks ancestors checking for duplicate-named siblings -- reuse the logic from `is_path_unique_with_cache` in [ref_properties.rs](src/syncback/ref_properties.rs) adapted for `RojoTree` instead of `WeakDom`.
---
## Issue 7: DRY -- Rojo_Target_* Attribute Name Formatting
**File:** [ref_properties.rs](src/syncback/ref_properties.rs) line 251
The ID-based `Rojo_Target_*` system uses inline `format!("{REF_POINTER_ATTRIBUTE_PREFIX}{}", link.name)` while the path-based system uses the shared `ref_attribute_name()` helper. Add a matching helper:
```rust
// In rojo_ref.rs:
pub fn ref_target_attribute_name(prop_name: &str) -> String {
format!("{REF_POINTER_ATTRIBUTE_PREFIX}{prop_name}")
}
```
Update `ref_properties.rs` line 251 to use it.
---
## Issue 8: DRY -- BinaryString to UTF-8 Attribute Value Parsing
**Files:** [patch_compute.rs](src/snapshot/patch_compute.rs) and [patch_apply.rs](src/snapshot/patch_apply.rs)
Both `compute_ref_properties` and `defer_ref_properties` have identical logic for parsing attribute values that may be `Variant::String` or `Variant::BinaryString`:
```rust
let path = match attr_value {
Variant::String(str) => str.as_str(),
Variant::BinaryString(bytes) => {
if let Ok(str) = std::str::from_utf8(bytes.as_ref()) {
str
} else {
log::warn!("...");
continue;
}
}
_ => {
log::warn!("...");
continue;
}
};
```
Extract to a shared helper in [rojo_ref.rs](src/rojo_ref.rs):
```rust
/// Extract a string value from a Variant that may be String or BinaryString.
/// Returns None with a warning for non-string types or invalid UTF-8.
pub fn variant_as_str<'a>(value: &'a Variant, attr_name: &str) -> Option<&'a str> {
match value {
Variant::String(s) => Some(s.as_str()),
Variant::BinaryString(bytes) => std::str::from_utf8(bytes.as_ref()).ok().or_else(|| {
log::warn!("Attribute {attr_name} contains invalid UTF-8");
None
}),
_ => {
log::warn!("Attribute {attr_name} is {:?}, expected String", value.ty());
None
}
}
}
```
---
## Regression Tests
The audit already added 5 regression tests (section 10a-10d). After implementing these fixes:
- `ref_stale_path_after_target_rename` should PASS
- `ref_stale_path_after_parent_rename` should PASS
- `ref_to_instance_added_in_same_request` should still FAIL (separate issue: WeakDom custom IDs)
- Lua spec tests 10c (untracked ref) should still FAIL (by design: untracked instances can't be encoded)
- Lua spec test 10d (missing instanceMap) should still FAIL (test deliberately omits instanceMap arg; fix 1 fixes ServeSession.lua:738 which is a different call site)
### New tests needed:
- Instance name with "/" in Ref path: set PrimaryPart to an instance named "A/B", verify path is escaped and resolves back
- Priority conflict: file with both Rojo_Ref_* and Rojo_Target_* for same property, verify Rojo_Ref_* wins
- CLI syncback parity: build an rbxl with Ref properties, run both CLI syncback and two-way sync, compare output files
- Concurrent Ref changes: set Part0 and Part1 on a WeldConstraint in the same batch, verify both Rojo_Ref_* attributes appear
- Edge cases: Ref targeting a ProjectNode instance, Ref targeting an instance in a nested project, Ref with a property name not in rbx_reflection

View file

@ -735,7 +735,18 @@ function ServeSession:__confirmAndApplyInitialPatch(catchUpPatch, serverInfo)
end end
end end
Log.info("[Pull] Update: {}", instancePath) Log.info("[Pull] Update: {}", instancePath)
local update = encodePatchUpdate(instance, change.id, propertiesToSync, self.__instanceMap) local update = encodePatchUpdate(
instance,
change.id,
propertiesToSync,
self.__instanceMap,
function(sourceInstance, propertyName, targetInstance)
-- Defer unresolved Refs for retry via ChangeBatcher.
-- After the pull patch is sent, the target may appear
-- in the InstanceMap via forward-sync reconciliation.
self.__changeBatcher:deferUnresolvedRef(sourceInstance, propertyName, targetInstance)
end
)
if update then if update then
table.insert(pullPatch.updated, update) table.insert(pullPatch.updated, update)
end end

View file

@ -413,8 +413,12 @@ impl JobThreadContext {
// The old and new tree path segments (last component of each path). // The old and new tree path segments (last component of each path).
// Used to map stale InstigatingSource paths to their actual on-disk // Used to map stale InstigatingSource paths to their actual on-disk
// locations after a directory rename. // locations after a directory rename.
let old_segment = old_path.rsplit('/').next().unwrap_or(old_path); // Use split_ref_path for escape-aware splitting (handles instance
let new_segment = new_path.rsplit('/').next().unwrap_or(new_path); // names containing "/" which are escaped as "\/" in ref paths).
let old_segments = crate::split_ref_path(old_path);
let old_segment = old_segments.last().map(|s| s.as_str()).unwrap_or(old_path);
let new_segments = crate::split_ref_path(new_path);
let new_segment = new_segments.last().map(|s| s.as_str()).unwrap_or(new_path);
// Collect all filesystem paths from instances in the tree. // Collect all filesystem paths from instances in the tree.
// We check each instance's instigating source for meta/model files. // We check each instance's instigating source for meta/model files.
@ -424,11 +428,31 @@ impl JobThreadContext {
// If the InstigatingSource path contains the old segment (stale // If the InstigatingSource path contains the old segment (stale
// after directory rename), map it to the new segment to find the // after directory rename), map it to the new segment to find the
// actual file on disk. // actual file on disk. Use targeted path component replacement
// to avoid false matches on substrings (e.g., replacing "Part"
// inside "PartContainer").
let inst_path = { let inst_path = {
let p_str = inst_path.to_string_lossy(); if !inst_path.exists() {
if !inst_path.exists() && p_str.contains(old_segment) { let mut components: Vec<_> = inst_path.components().collect();
PathBuf::from(p_str.replace(old_segment, new_segment).as_str()) let mut replaced = false;
for comp in &mut components {
if let std::path::Component::Normal(os_str) = comp {
if let Some(s) = os_str.to_str() {
if s == old_segment {
*comp = std::path::Component::Normal(
std::ffi::OsStr::new(new_segment),
);
replaced = true;
break; // Only replace the first exact match
}
}
}
}
if replaced {
components.iter().collect::<PathBuf>()
} else {
inst_path.to_path_buf()
}
} else { } else {
inst_path.to_path_buf() inst_path.to_path_buf()
} }
@ -973,7 +997,7 @@ impl JobThreadContext {
// Capture the old path BEFORE rename for Rojo_Ref_* path updates. // Capture the old path BEFORE rename for Rojo_Ref_* path updates.
// Must be computed before the `tree.get_instance(id)` borrow. // Must be computed before the `tree.get_instance(id)` borrow.
let old_ref_path = if update.changed_name.is_some() { let old_ref_path = if update.changed_name.is_some() {
Some(tree.inner().full_path_of(id, "/")) Some(crate::ref_target_path(tree.inner(), id))
} else { } else {
None None
}; };
@ -1488,15 +1512,27 @@ impl JobThreadContext {
// NOTE: The tree name hasn't been updated yet (apply_patch_set // NOTE: The tree name hasn't been updated yet (apply_patch_set
// runs after this loop), so we can't use full_path_of for the // runs after this loop), so we can't use full_path_of for the
// new path. Construct it by replacing the last path segment. // new path. Construct it by replacing the last path segment.
// Use split_ref_path for escape-aware splitting (handles
// instance names containing "/").
if let Some(ref old_ref_path) = old_ref_path { if let Some(ref old_ref_path) = old_ref_path {
if let Some(ref new_name) = update.changed_name { if let Some(ref new_name) = update.changed_name {
let new_ref_path = let segments = crate::split_ref_path(old_ref_path);
if let Some((parent, _old_name)) = old_ref_path.rsplit_once('/') { let new_ref_path = if segments.len() > 1 {
format!("{}/{}", parent, crate::escape_ref_path_segment(new_name)) // Rebuild parent path from all segments except the last,
} else { // re-escaping each segment to preserve the original format.
// Root-level instance (no parent in path) let parent_parts: Vec<_> = segments[..segments.len() - 1]
crate::escape_ref_path_segment(new_name).into_owned() .iter()
}; .map(|s| crate::escape_ref_path_segment(s).into_owned())
.collect();
format!(
"{}/{}",
parent_parts.join("/"),
crate::escape_ref_path_segment(new_name)
)
} else {
// Root-level instance (no parent in path)
crate::escape_ref_path_segment(new_name).into_owned()
};
if *old_ref_path != new_ref_path { if *old_ref_path != new_ref_path {
self.update_ref_paths_after_rename( self.update_ref_paths_after_rename(
&mut tree, &mut tree,

View file

@ -288,25 +288,33 @@ fn compute_ref_properties(
}; };
for (attr_name, attr_value) in attributes.iter() { for (attr_name, attr_value) in attributes.iter() {
// Handle legacy ID-based refs (Rojo_Target_) FIRST -- lower priority. // Handle legacy ID-based refs (Rojo_Target_) -- lower priority.
// If a Rojo_Ref_* attribute also exists for the same property, it will // BTreeMap iterates alphabetically, so Rojo_Ref_* ('R') is visited
// overwrite this entry below (path-based system has priority). // BEFORE Rojo_Target_* ('T'). Without the contains_key guard,
// Rojo_Target_* would silently overwrite Rojo_Ref_*. The guard
// ensures path-based refs always win when both exist.
if let Some(prop_name) = attr_name.strip_prefix(REF_POINTER_ATTRIBUTE_PREFIX) { if let Some(prop_name) = attr_name.strip_prefix(REF_POINTER_ATTRIBUTE_PREFIX) {
let key = ustr(prop_name);
// Skip if Rojo_Ref_* already set a value for this property
if map.contains_key(&key) {
continue;
}
let Some(id_str) = crate::variant_as_str(attr_value, attr_name) else { let Some(id_str) = crate::variant_as_str(attr_value, attr_name) else {
continue; continue;
}; };
let rojo_ref = RojoRef::new(id_str.to_string()); let rojo_ref = RojoRef::new(id_str.to_string());
if let Some(target_id) = tree.get_specified_id(&rojo_ref) { if let Some(target_id) = tree.get_specified_id(&rojo_ref) {
map.insert(ustr(prop_name), Some(Variant::Ref(target_id))); map.insert(key, Some(Variant::Ref(target_id)));
} else { } else {
map.insert(ustr(prop_name), None); map.insert(key, None);
} }
continue; continue;
} }
// Handle path-based refs (Rojo_Ref_) SECOND -- higher priority. // Handle path-based refs (Rojo_Ref_) -- higher priority.
// Overwrites any Rojo_Target_* entry for the same property since // Always inserted unconditionally; if a Rojo_Target_* for the same
// path-based is the preferred system. // property was already visited (shouldn't happen due to BTreeMap
// ordering, but defensive), this overwrites it.
if let Some(prop_name) = attr_name.strip_prefix(REF_PATH_ATTRIBUTE_PREFIX) { if let Some(prop_name) = attr_name.strip_prefix(REF_PATH_ATTRIBUTE_PREFIX) {
let Some(path) = crate::variant_as_str(attr_value, attr_name) else { let Some(path) = crate::variant_as_str(attr_value, attr_name) else {
continue; continue;

View file

@ -230,7 +230,7 @@ fn ref_attr_resolves_primary_part() {
let has_primary_part_ref = patch_set.updated_instances.iter().any(|u| { let has_primary_part_ref = patch_set.updated_instances.iter().any(|u| {
u.changed_properties u.changed_properties
.get(&ustr("PrimaryPart")) .get(&ustr("PrimaryPart"))
.map_or(false, |v| matches!(v, Some(Variant::Ref(r)) if r.is_some())) .is_some_and(|v| matches!(v, Some(Variant::Ref(r)) if r.is_some()))
}); });
assert!( assert!(
has_primary_part_ref, has_primary_part_ref,
@ -280,7 +280,7 @@ fn ref_attr_resolves_value() {
let has_value_ref = patch_set.updated_instances.iter().any(|u| { let has_value_ref = patch_set.updated_instances.iter().any(|u| {
u.changed_properties u.changed_properties
.get(&ustr("Value")) .get(&ustr("Value"))
.map_or(false, |v| matches!(v, Some(Variant::Ref(r)) if r.is_some())) .is_some_and(|v| matches!(v, Some(Variant::Ref(r)) if r.is_some()))
}); });
assert!( assert!(
has_value_ref, has_value_ref,
@ -343,11 +343,11 @@ fn ref_attr_multiple_on_same_instance() {
let has_pp = update let has_pp = update
.changed_properties .changed_properties
.get(&ustr("PrimaryPart")) .get(&ustr("PrimaryPart"))
.map_or(false, |v| matches!(v, Some(Variant::Ref(r)) if r.is_some())); .is_some_and(|v| matches!(v, Some(Variant::Ref(r)) if r.is_some()));
let has_custom = update let has_custom = update
.changed_properties .changed_properties
.get(&ustr("CustomRef")) .get(&ustr("CustomRef"))
.map_or(false, |v| matches!(v, Some(Variant::Ref(r)) if r.is_some())); .is_some_and(|v| matches!(v, Some(Variant::Ref(r)) if r.is_some()));
assert!(has_pp, "PrimaryPart ref should resolve"); assert!(has_pp, "PrimaryPart ref should resolve");
assert!(has_custom, "CustomRef ref should resolve"); assert!(has_custom, "CustomRef ref should resolve");
@ -393,7 +393,7 @@ fn ref_attr_nonexistent_path_does_not_produce_valid_ref() {
let has_valid_ref = patch_set.updated_instances.iter().any(|u| { let has_valid_ref = patch_set.updated_instances.iter().any(|u| {
u.changed_properties u.changed_properties
.get(&ustr("PrimaryPart")) .get(&ustr("PrimaryPart"))
.map_or(false, |v| matches!(v, Some(Variant::Ref(r)) if r.is_some())) .is_some_and(|v| matches!(v, Some(Variant::Ref(r)) if r.is_some()))
}); });
assert!( assert!(
!has_valid_ref, !has_valid_ref,
@ -448,7 +448,7 @@ fn ref_attr_with_regular_attributes_mixed() {
let has_pp = patch_set.updated_instances.iter().any(|u| { let has_pp = patch_set.updated_instances.iter().any(|u| {
u.changed_properties u.changed_properties
.get(&ustr("PrimaryPart")) .get(&ustr("PrimaryPart"))
.map_or(false, |v| matches!(v, Some(Variant::Ref(_)))) .is_some_and(|v| matches!(v, Some(Variant::Ref(_))))
}); });
assert!(has_pp, "PrimaryPart should be resolved from Rojo_Ref_*"); assert!(has_pp, "PrimaryPart should be resolved from Rojo_Ref_*");
} }
@ -486,10 +486,108 @@ fn ref_attr_empty_path_resolves_to_root() {
// Empty path should resolve to root (RojoTree.get_instance_by_path("") returns root) // Empty path should resolve to root (RojoTree.get_instance_by_path("") returns root)
let has_ref = patch_set.updated_instances.iter().any(|u| { let has_ref = patch_set.updated_instances.iter().any(|u| {
u.changed_properties.get(&ustr("PrimaryPart")).map_or( u.changed_properties.get(&ustr("PrimaryPart")).is_some_and(
false,
|v| matches!(v, Some(Variant::Ref(r)) if *r == root_id), |v| matches!(v, Some(Variant::Ref(r)) if *r == root_id),
) )
}); });
assert!(has_ref, "Empty path should resolve to root instance"); assert!(has_ref, "Empty path should resolve to root instance");
} }
/// When both Rojo_Ref_PrimaryPart and Rojo_Target_PrimaryPart exist for the
/// same property, Rojo_Ref_* (path-based) must win because it is the preferred
/// system. BTreeMap iterates alphabetically ('R' < 'T'), so Rojo_Ref_* is
/// visited first. The Rojo_Target_* branch must skip insertion if Rojo_Ref_*
/// already set the property.
#[test]
fn ref_attr_priority_path_wins_over_target() {
// Build tree: ROOT > Workspace > Model > Target, OtherPart
let snapshot = InstanceSnapshot::new()
.name("ROOT")
.class_name("DataModel")
.children(vec![InstanceSnapshot::new()
.name("Workspace")
.class_name("Workspace")
.children(vec![InstanceSnapshot::new()
.name("Model")
.class_name("Model")
.children(vec![
InstanceSnapshot::new().name("Target").class_name("Part"),
InstanceSnapshot::new().name("OtherPart").class_name("Part"),
])])]);
let mut tree = RojoTree::new(snapshot);
let root_id = tree.get_root_id();
let workspace_id = tree
.get_instance(root_id)
.unwrap()
.children()
.first()
.copied()
.unwrap();
let model_id = tree
.get_instance(workspace_id)
.unwrap()
.children()
.first()
.copied()
.unwrap();
let model_children: Vec<_> = tree
.get_instance(model_id)
.unwrap()
.children()
.to_vec();
let target_id = model_children[0]; // Target
let other_id = model_children[1]; // OtherPart
// Give OtherPart a specified ID so Rojo_Target_* can resolve it
let other_rojo_id = crate::RojoRef::new("other-rojo-id".to_string());
tree.set_specified_id(other_id, other_rojo_id);
// Create snapshot with BOTH attributes for the same property.
// Rojo_Ref_PrimaryPart points to Target (via path).
// Rojo_Target_PrimaryPart points to OtherPart (via ID).
// Rojo_Ref_* should win.
let mut attrs = Attributes::new();
attrs.insert(
"Rojo_Ref_PrimaryPart".into(),
Variant::String("Workspace/Model/Target".into()),
);
attrs.insert(
"Rojo_Target_PrimaryPart".into(),
Variant::String("other-rojo-id".into()),
);
let snap = InstanceSnapshot::new()
.name("Model")
.class_name("Model")
.properties(UstrMap::from_iter([(
ustr("Attributes"),
Variant::Attributes(attrs),
)]))
.children(vec![
InstanceSnapshot::new().name("Target").class_name("Part"),
InstanceSnapshot::new().name("OtherPart").class_name("Part"),
]);
let patch_set = compute_patch_set(Some(snap), &tree, model_id);
// PrimaryPart should point to Target (from Rojo_Ref_*), NOT OtherPart (from Rojo_Target_*)
let pp_ref = patch_set
.updated_instances
.iter()
.find_map(|u| u.changed_properties.get(&ustr("PrimaryPart")))
.expect("PrimaryPart should be in the patch");
match pp_ref {
Some(Variant::Ref(r)) => {
assert_eq!(
*r, target_id,
"Rojo_Ref_* should win over Rojo_Target_* -- PrimaryPart should point to Target, not OtherPart"
);
}
other => panic!(
"PrimaryPart should be Some(Variant::Ref), got {:?}",
other
),
}
}

View file

@ -593,6 +593,32 @@ impl ApiService {
// Log summary of any syncback issues // Log summary of any syncback issues
stats.log_summary(); stats.log_summary();
// Build a path map for instances added in this same request.
// This allows Ref properties in updates to reference just-added
// instances that aren't in the tree yet (VFS watcher is async).
let added_paths: HashMap<Ref, String> = {
let tree = self.serve_session.tree();
request
.added
.iter()
.filter_map(|(guid, added)| {
let parent_ref = added.parent?;
let parent_path = if tree.get_instance(parent_ref).is_some() {
crate::ref_target_path(tree.inner(), parent_ref)
} else {
return None;
};
let escaped_name = crate::escape_ref_path_segment(&added.name);
let path = if parent_path.is_empty() {
escaped_name.into_owned()
} else {
format!("{}/{}", parent_path, escaped_name)
};
Some((*guid, path))
})
.collect()
};
// Persist non-Source property changes to disk (meta/model files). // Persist non-Source property changes to disk (meta/model files).
// Source property changes are handled by ChangeProcessor when it // Source property changes are handled by ChangeProcessor when it
// receives the PatchSet below. // receives the PatchSet below.
@ -620,7 +646,9 @@ impl ApiService {
} }
// Write non-Source properties to meta/model files // Write non-Source properties to meta/model files
if let Err(err) = self.syncback_updated_properties(update, &tree) { if let Err(err) =
self.syncback_updated_properties(update, &tree, &added_paths)
{
log::warn!( log::warn!(
"Failed to persist non-Source properties for instance {:?}: {}", "Failed to persist non-Source properties for instance {:?}: {}",
update.id, update.id,
@ -2547,6 +2575,7 @@ impl ApiService {
&self, &self,
update: &crate::web::interface::InstanceUpdate, update: &crate::web::interface::InstanceUpdate,
tree: &crate::snapshot::RojoTree, tree: &crate::snapshot::RojoTree,
added_paths: &HashMap<Ref, String>,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
use anyhow::Context; use anyhow::Context;
@ -2629,6 +2658,20 @@ impl ApiService {
} }
ref_attributes.insert(attr_name, serde_json::Value::String(path)); ref_attributes.insert(attr_name, serde_json::Value::String(path));
} else if let Some(path) = added_paths.get(target_ref) {
// Target was added in the same /api/write request.
// It's not in the tree yet (VFS watcher hasn't picked it up),
// but we can compute the path from the AddedInstance data.
log::info!(
"Ref property '{}' for instance {:?}: target {:?} is a \
just-added instance, using pre-computed path '{}'",
key,
update.id,
target_ref,
path
);
ref_attributes
.insert(attr_name, serde_json::Value::String(path.clone()));
} else { } else {
log::warn!( log::warn!(
"Cannot persist Ref property '{}' for instance {:?}: \ "Cannot persist Ref property '{}' for instance {:?}: \

View file

@ -6712,7 +6712,6 @@ fn ref_stale_path_after_parent_rename() {
/// syncback_updated_properties runs because additions go through the VFS /// syncback_updated_properties runs because additions go through the VFS
/// watcher asynchronously. /// watcher asynchronously.
#[test] #[test]
#[ignore = "Known limitation: WeakDom doesn't support custom Ref IDs, so new instances aren't in the tree when Ref properties are processed in the same request"]
fn ref_to_instance_added_in_same_request() { fn ref_to_instance_added_in_same_request() {
run_serve_test("ref_two_way_sync", |session, _| { run_serve_test("ref_two_way_sync", |session, _| {
let (session_id, _, model_id, _, _, _) = ref_test_setup(&session); let (session_id, _, model_id, _, _, _) = ref_test_setup(&session);