fix(mutations): treat a base null-valued property as present, not absent - #3032
fix(mutations): treat a base null-valued property as present, not absent#3032BIMvoice wants to merge 5 commits into
Conversation
… UPDATE MutablePropertyView.setProperty()'s propExistedBefore check only tested oldValue !== null and the in-session newPsets map, so the first edit of a property that already exists in the BASE property table with a null value (e.g. an unset Boolean parsed from the source IFC file — the #1107 shape, but on the base-table path rather than the newPsets one) was misclassified as CREATE_PROPERTY instead of UPDATE_PROPERTY. deleteProperty() already guards against exactly this with its own propExistsInBase lookup against the base pset's property list; setProperty() now checks the same thing.
|
Warning Review limit reached
Next review available in: 25 minutes Limit details: You’ve used all 2 included reviews currently available. You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. How can I continue?Wait for the limit to reset, then comment An organization admin can change what happens after included review limits in Billing. How do review limits work?CodeRabbit enforces per-developer PR review limits within each organization. For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (3)
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. Comment |
Viewer benchmark✅ No threshold regressions detected. 01_Snowdon_Towers_Sample_Structural(1).ifcBaseline recorded 2026-07-01T20:31:05.538Z on github-actions ubuntu-latest, viewer-benchmark-ci (headless Chrome, SwiftShader ANGLE), production build.
AC20-FZK-Haus.ifcBaseline recorded 2026-07-01T20:30:59.972Z on github-actions ubuntu-latest, viewer-benchmark-ci (headless Chrome, SwiftShader ANGLE), production build.
Refresh the baseline from a CI run: dispatch the Benchmark workflow with |
|
The latest updates on your projects. Learn more about Vercel for GitHub. 2 Skipped Deployments
|
The base-pset disjunct added for the #1107 base-table shape counted a base property as present even after the user had deleted it, so re-setting it came back as UPDATE_PROPERTY with `oldValue: null` instead of CREATE_PROPERTY. The viewer's undo handler decides by mutation TYPE (mutationSlice.ts:2693), so undoing that re-set replayed the null and resurrected the deleted property as a present-but-unset row rather than removing it. Same for a property whose whole base pset was deleted. Qualify the disjunct with "not currently masked in this session" -- a DELETE marker on the key, or the pset in `deletedPsets`. Three tests: both delete shapes stay CREATE, and an undeleted base property still classifies as UPDATE so the guard is not blanket. With the qualifier removed the two delete tests fail and the third passes.
|
Self-review found a defect in this PR's own fix, now corrected — pushed The submitted version counted a base property as present even after the user had deleted it. Measured before and after the new disjunct:
The consequence runs through undo: The disjunct is now qualified with "not currently masked in this session" — a I have also rewritten the body. It described One pre-existing bug found while probing, not fixed here: 206 pass / 10 skip, typecheck green. |
…t per-pset Every base-pset fixture in mutable-property-view.test.ts contains exactly the property being set, so "this pset exists in base" and "this property exists in base" coincide and a per-PSET existence check reads as correct. Verified by mutation: relaxing the new disjunct to `basePsets.some(p => p.name === psetName)` — dropping the inner `p.properties.some(prop => prop.name === propName)` — left all 28 tests green. That wrong version classifies every brand-new property added to an existing pset as an UPDATE with `oldValue: null`; since the viewer's undo handler decides by mutation TYPE (mutationSlice.ts), undoing such an add would replay the null and leave the property behind as a present-but-unset row instead of removing it — the same failure mode the deleted-base-property case guards. Adds a fixture whose base pset holds IsExternal while LoadBearing is set: the new property must be a CREATE and the sibling still an UPDATE carrying its base value. The mutation now fails.
|
Fixture-symmetry sweep found this PR's own fixtures could not distinguish the two conditions its fix turns on. Fixed, pushed Every base-pset fixture contained exactly the property being set, so "pset exists in base" and "property exists in base" coincided in all of them. VERIFIED BY RUNNING: relaxing the new disjunct to That relaxation is not harmless. It classifies every new property added to an existing pset as Fix: a pset holding |
|
Reviewed this properly, since CodeRabbit was rate-limited on it and the green is therefore not a review. It holds up. One low-severity gap, no defects. Verified by running, not readingThe new disjunct is a name membership test over the base pset's property array, so it is value-independent by construction. Probed every value shape against the real slice: Present-with-null and absent are genuinely different states here, which is the thing this family of bug keeps getting wrong. Worth stating explicitly because "does it also handle No prototype exposure either: every lookup is The one finding, low severity
It survives because The clause is right and the code is right. There is simply no test that fails if someone deletes it, which is the untested vs redundant ambiguity — a surviving mutant is a question, not a verdict, and here the answer is "the only bound for one route". One test on that sequence closes it. Positive controls are present and real: 207 passed / 10 skipped, typecheck OK. |
deletePropertySet also writes a per-property DELETE marker, so the two halves of `maskedInSession` overlapped in every existing fixture and the `deletedPsets` half was killed by nothing. Verified by mutation: dropping it left all 29 tests green. The halves separate once the DELETE marker has been overwritten, which the first re-set does. A second re-set with a null value then has no marker and a null oldValue, so only `deletedPsets` still masks the base row; without it the re-set classifies as UPDATE with oldValue null, resurrecting the deleted property on undo. The new test now fails under that mutation and is the only one that does.
|
Gap closed, pushed You were right that the Mutation-verified rather than assumed: dropping That is the second half of the same two-way rule this PR is about. The first version guarded the property-level mask and left the pset-level one unpinned, so a regression that stopped honouring a deleted pset would have shipped green. |
Fixes
MutablePropertyView.setProperty()misclassifying the first edit of an already-present-but-null base property asCREATE_PROPERTYinstead ofUPDATE_PROPERTY.The defect
propExistedBeforerelied onoldValue !== nullplus an in-sessionnewPsetscheck. Neither sees a property that exists in the base property table with a null value — an unset Boolean parsed from the source IFC file, the same shape as #1107 — becausegetPropertyValue()legitimately returns null for it before any edit.deleteProperty()already avoids this trap with its ownpropExistsInBaselookup against the base pset's property list.setProperty()now checks the same thing.It matters because the mutation type is the contract:
mutationSlice.ts:2693decides undo by type — its own comment says so. Undoing aCREATEdeletes the property; undoing anUPDATEreplaysoldValue.The two-way half, added after an adversarial pass
The first version of this fix counted a base property as present even after the user had deleted it. Measured, before and after that disjunct:
deletePropertythensetPropertyon a base propCREATE_PROPERTYUPDATE_PROPERTY, oldValue nulldeletePropertySetthensetPropertyCREATE_PROPERTYUPDATE_PROPERTY, oldValue nullSo after delete-then-re-set, undo would replay
nulland resurrect the deleted property as a present-but-unset row instead of removing it — exactly the failure this PR exists to prevent, reintroduced on the adjacent path.The base-pset lookup now only counts a row that is still visible: a property the user has deleted, directly or by deleting its whole pset, is masked out of
getForEntity(), so re-setting it stays aCREATE_PROPERTY.Three tests: both delete shapes stay
CREATE; an undeleted base property still classifies asUPDATEwith its trueoldValue, so the guard is not blanket.Revert-verified rather than read: removing the
!maskedInSessionqualifier fails exactly the two delete tests and leaves the third green.@ifc-lite/mutations206 pass / 10 skip; typecheck green.A pre-existing bug found while probing this, not fixed here
deletePropertySet(7,'Pset_Base')thensetProperty(7,'Pset_Base','Status',…)leavesgetForEntity(7) === []— the write silently vanishes.psetExistsInBaseis true so nothing goes intonewPsets, andgetForEntityskips the whole pset viadeletedPsets(mutable-property-view.ts:314).Reproduced on
upstream/main, so it predates this PR and is out of scope here. Worth its own issue.🤖 Generated with Claude Code