Skip to content

fix(mutations): treat a base null-valued property as present, not absent - #3032

Open
BIMvoice wants to merge 5 commits into
mainfrom
untouched-packages-sweep
Open

fix(mutations): treat a base null-valued property as present, not absent#3032
BIMvoice wants to merge 5 commits into
mainfrom
untouched-packages-sweep

Conversation

@BIMvoice

@BIMvoice BIMvoice commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

Fixes MutablePropertyView.setProperty() misclassifying the first edit of an already-present-but-null base property as CREATE_PROPERTY instead of UPDATE_PROPERTY.

The body previously here described source-msgraph's page-limit and sign-out fixes — that is #3031's content, pasted into the wrong PR. This diff is three files, all under packages/mutations. Corrected.

The defect

propExistedBefore relied on oldValue !== null plus an in-session newPsets check. 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 — because getPropertyValue() legitimately returns null for it before any edit.

deleteProperty() already avoids this trap with its own propExistsInBase lookup against the base pset's property list. setProperty() now checks the same thing.

It matters because the mutation type is the contract: mutationSlice.ts:2693 decides undo by type — its own comment says so. Undoing a CREATE deletes the property; undoing an UPDATE replays oldValue.

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:

sequence pre-PR first version of the fix
deleteProperty then setProperty on a base prop CREATE_PROPERTY UPDATE_PROPERTY, oldValue null
deletePropertySet then setProperty CREATE_PROPERTY UPDATE_PROPERTY, oldValue null

So after delete-then-re-set, undo would replay null and 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 a CREATE_PROPERTY.

Three tests: both delete shapes stay CREATE; an undeleted base property still classifies as UPDATE with its true oldValue, so the guard is not blanket.

Revert-verified rather than read: removing the !maskedInSession qualifier fails exactly the two delete tests and leaves the third green.

@ifc-lite/mutations 206 pass / 10 skip; typecheck green.

A pre-existing bug found while probing this, not fixed here

deletePropertySet(7,'Pset_Base') then setProperty(7,'Pset_Base','Status',…) leaves getForEntity(7) === [] — the write silently vanishes. psetExistsInBase is true so nothing goes into newPsets, and getForEntity skips the whole pset via deletedPsets (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

… 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.
@BIMvoice
BIMvoice requested a review from louistrue as a code owner August 21, 2026 13:52
@coderabbitai

coderabbitai Bot commented Aug 21, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@BIMvoice, you've reached your PR review limit, so we couldn't start this review.

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 @coderabbitai review or push new commits to the PR.

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 configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 7525ddcd-2e15-42ed-8589-651d27e3259c

📥 Commits

Reviewing files that changed from the base of the PR and between fe38b33 and 64c7aee.

📒 Files selected for processing (3)
  • .changeset/mutations-set-property-base-null-classification.md
  • packages/mutations/src/mutable-property-view.ts
  • packages/mutations/test/mutable-property-view.test.ts

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.

@github-actions

github-actions Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Viewer benchmark

✅ No threshold regressions detected.

01_Snowdon_Towers_Sample_Structural(1).ifc

Baseline recorded 2026-07-01T20:31:05.538Z on github-actions ubuntu-latest, viewer-benchmark-ci (headless Chrome, SwiftShader ANGLE), production build.

Metric Current Baseline Delta Threshold Status
firstBatchWaitMs 1835ms 2905ms -36.8% +50%
firstVisibleGeometryMs 2641ms 3652ms -27.7% +50%
streamCompleteMs 3119ms 3598ms -13.3% +50%
spatialReadyMs 1377ms 1032ms +33.4% +50%
metadataCompleteMs 1962ms 3063ms -35.9% +50%
totalWallClockMs 3600ms 3700ms -2.7% +50%

AC20-FZK-Haus.ifc

Baseline recorded 2026-07-01T20:30:59.972Z on github-actions ubuntu-latest, viewer-benchmark-ci (headless Chrome, SwiftShader ANGLE), production build.

Metric Current Baseline Delta Threshold Status
firstBatchWaitMs 372ms 1075ms -65.4% +50%
firstVisibleGeometryMs 1349ms 1572ms -14.2% +50%
streamCompleteMs 1103ms 1980ms -44.3% +50%
spatialReadyMs 1139ms 915ms +24.5% +50%
metadataCompleteMs 1233ms 1392ms -11.4% +50%
totalWallClockMs 1500ms 3300ms -54.5% +50%

Refresh the baseline from a CI run: dispatch the Benchmark workflow with record_baseline, download the benchmark-baseline artifact, and commit baseline.json (see tests/benchmark/README.md).

@vercel

vercel Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

2 Skipped Deployments
Project Deployment Actions Updated (UTC)
ifc-lite-dev Ignored Ignored Preview Aug 22, 2026 11:56am
ifc-lite-viewer-embed Ignored Ignored Aug 22, 2026 11:56am

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.
@BIMvoice

Copy link
Copy Markdown
Collaborator Author

Self-review found a defect in this PR's own fix, now corrected — pushed d6267a8b3. Flagging it rather than quietly amending, because it is the shape this PR exists to prevent, reintroduced on the adjacent path.

The submitted version counted a base property as present even after the user had deleted it. Measured before and after the new disjunct:

sequence pre-PR as submitted
deleteProperty then setProperty on a base prop CREATE_PROPERTY UPDATE_PROPERTY, oldValue null
deletePropertySet then setProperty CREATE_PROPERTY UPDATE_PROPERTY, oldValue null

The consequence runs through undo: mutationSlice.ts:2693 decides undo by mutation type — its own comment says so — so undoing a CREATE deletes the property while undoing an UPDATE replays oldValue. After delete-then-re-set, undo would have replayed null and resurrected the deleted property as a present-but-unset row instead of removing it. That is the exact failure the changeset names as the reason for the fix.

The disjunct is now qualified 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 with its true oldValue, so the guard is not blanket. Revert-verified rather than read: removing the qualifier fails exactly the two delete tests and leaves the third green.

I have also rewritten the body. It described source-msgraph's page-limit and sign-out fixes — #3031's content, in the wrong PR. This diff is three files, all under packages/mutations. A reviewer reading top-down was reviewing a diff that is not here. My error, and the second time this week; I am checking bodies against gh pr diff --name-only before posting from now on.

One pre-existing bug found while probing, not fixed here: deletePropertySet(7,'Pset_Base') then setProperty(7,'Pset_Base','Status',…) leaves getForEntity(7) === [] — the write silently vanishes. psetExistsInBase is true so nothing enters newPsets, and getForEntity skips the whole pset via deletedPsets (mutable-property-view.ts:314). Reproduced on upstream/main, so it predates this PR. Happy to file it or fix it separately — say which you prefer.

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.
@BIMvoice

Copy link
Copy Markdown
Collaborator Author

Fixture-symmetry sweep found this PR's own fixtures could not distinguish the two conditions its fix turns on. Fixed, pushed 92cec36bb.

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 basePsets.some(p => p.name === psetName) — dropping the inner property check entirely — left 28/28 green.

That relaxation is not harmless. It classifies every new property added to an existing pset as UPDATE with oldValue: null, so undo would leave a present-but-unset row rather than removing it — which is the exact failure this PR's changeset names as its reason for existing, arriving by a third route.

Fix: a pset holding IsExternal while LoadBearing is the property being set, so the two conditions come apart. The relaxation now fails.

@louistrue

Copy link
Copy Markdown
Collaborator

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 reading

The 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:

base value=null         -> UPDATE_PROPERTY  oldValue=null
base value=zero         -> UPDATE_PROPERTY  oldValue=0
base value=emptyString  -> UPDATE_PROPERTY  oldValue=
base value=false        -> UPDATE_PROPERTY  oldValue=false
base value=NaN          -> UPDATE_PROPERTY  oldValue=NaN
base ABSENT             -> CREATE_PROPERTY  oldValue=null

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 0?" is the obvious next question and the answer is not "it was fixed too": 0, '', false and NaN were never broken. getPropertyValue returns them verbatim and oldValue !== null was already true for all of them. null was the only ambiguous value. So this is a complete fix, not a partial one that reads as complete.

No prototype exposure either: every lookup is Array.prototype.some with ===, Map.get, or Set.has — no bracket access on a plain object. Probed constructor, __proto__, toString and hasOwnProperty as both propName and psetName; all returned CREATE_PROPERTY.

The one finding, low severity

mutable-property-view.ts:476 — the deletedPsets half of maskedInSession is load-bearing and untested. Five clause-level mutants, four killed, this one survived:

M1 revert the whole base disjunct        killed
M2 drop !maskedInSession                 killed
M3 per-pset instead of per-property      killed
M5 drop the DELETE-marker half           killed
M4 drop the deletedPsets half            SURVIVED

It survives because deletePropertySet also writes a per-property DELETE marker, so the two halves overlap in every existing fixture. They stop overlapping once that marker is overwritten:

deletePropertySet(7,'Pset_Base'); setProperty(...,null); setProperty(...,null)
  with the clause    2nd call -> CREATE_PROPERTY   (correct, getForEntity(7) === [])
  without it         2nd call -> UPDATE_PROPERTY, oldValue=null

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: 'an undeleted base property is still an UPDATE (the guard is not blanket)' and the per-property test both fail if the classification is forced to always-present.

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.
@BIMvoice

Copy link
Copy Markdown
Collaborator Author

Gap closed, pushed 64c7aeee6.

You were right that the deletedPsets half of maskedInSession survives mutation. Added a test on the deletePropertySetsetProperty(null)setProperty(null) sequence.

Mutation-verified rather than assumed: dropping this.deletedPsets.has(...) from maskedInSession now fails exactly one test — the new one, with the other 29 green. Which also independently confirms your claim that M4 previously survived: before this, that mutation killed nothing.

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.

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.

2 participants