Skip to content

feat: report valueChanged per preserved key in onReconciliation - #3274

Open
christianhg wants to merge 1 commit into
mainfrom
markdown-value-changed
Open

christianhg wants to merge 1 commit into
mainfrom
markdown-value-changed

Conversation

@christianhg

@christianhg christianhg commented Sep 14, 2026

Copy link
Copy Markdown
Member

feat: report valueChanged per preserved key in onReconciliation

Each preservedKeys entry now carries a valueChanged boolean alongside basis, key, and path: whether the node wearing that key in the returned value deep-equals its stored counterpart, identical fields and values with field order aside. false is a guarantee, since a node the edit did not touch always comes back as the verbatim stored value, so filtering preservedKeys for valueChanged finds exactly what an edit touched:

const touched = report.preservedKeys.filter((entry) => entry.valueChanged)
// keys whose stored value the edit actually changed

The mechanism: the recorder gains a storedCounterpart map populated at the four adoption sites, and verbatim subtrees record pristine lockstep clones rather than self-references, so a mutation after adoption (the uniqueness pass renaming a duplicate stored key inside a preserved subtree) cannot fake a false. That hole was found in review and is pinned red-first. The compare runs only when the callback is set, and the report builder throws on a missing counterpart rather than defaulting.

One existing pin now demonstrates why the field earns its place next to basis: a markDef key fallback produces an entry with basis: 'content-unchanged' and valueChanged: true, the advisory tier and the actual fact disagreeing exactly where a consumer would have been misled. Fourteen existing full-report pins gained the literal, the fuzz oracle asserts the field against an independent deep-equal on every case, and a hardcoded-false mutation sends twenty tests red.


Note

Low Risk
Additive @beta report field and comparison logic gated on onReconciliation; no change to reconciled output when the callback is omitted.

Overview
Adds valueChanged to each preservedKeys entry in the onReconciliation report (@portabletext/markdown), so callers can tell whether the returned node deep-equals its stored counterpart (field order ignored on objects; array order still matters). Unlike advisory basis, valueChanged: false is documented as safe to branch on—filtering preserved keys by valueChanged surfaces nodes the edit actually mutated.

Implementation records a stored snapshot per adopted node via ReconciliationRecorder.storedCounterpart, filled at adoption and mark-def sites; verbatim/reinserted subtrees pair against pristine clones so a later sibling-uniqueness key rename cannot incorrectly report false. Report building runs deepEqualNodes only when onReconciliation is set and throws if a preserved key lacks a counterpart.

README and changeset document the API; reconciliation and fuzz tests assert the new field (including cases where basis says content-unchanged but the value did change).

Reviewed by Cursor Bugbot for commit d3ac697. Bugbot is set up for automated code reviews on this repo. Configure here.

The reconciliation report said what happened to keys but not whether
the content under a preserved key changed, and the first adopter
immediately needed exactly that: detecting whether an edit touched
`json:object` payloads it should have left alone. Every consumer would
hand-roll the same deep-compare, and the library can answer more
cheaply and exactly, since it holds each preserved node's stored
counterpart at the moment of adoption.

Each `preservedKeys` entry gains `valueChanged: boolean`: whether the
node wearing that key in the returned value deep-equals its stored
counterpart, identical fields and values with field order aside.
Unlike the advisory `basis`, this is a stable fact of the invocation,
safe to branch on, and the markDef-collision pin now demonstrates the
difference the field exists to expose: an entry can carry
`basis: 'content-unchanged'` with `valueChanged: true`, because a key
fallback rewrote the span's `marks` reference after the match.

The recorder gains a `storedCounterpart` WeakMap populated at the four
sites that record a preservation basis. Verbatim and empty-run
subtrees record pristine lockstep clones rather than self-references,
so a mutation after adoption (the sibling-uniqueness pass renaming a
duplicate stored key inside a preserved subtree) cannot fake a
`false`; that hole was found in review and pinned red-first with a
duplicate-keyed empty-run fixture. The compare is a local
`deepEqualNodes` (order-insensitive fields, order-sensitive arrays,
unexported), the report builder throws on a missing counterpart rather
than defaulting, and nothing is recorded or compared when the callback
is unset.

Contracts moved with the field: fourteen existing full-report pins
gained the literal, each derived from its fixture's semantics, the
fuzz report-consistency oracle asserts the field against an
independent local deep-equal and key map on every case, its
corrupted-report meta-test rejects a flipped `valueChanged`, and a
hardcoded-`false` mutation sends twenty tests red.
@vercel

vercel Bot commented Sep 14, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated
portable-text-editor-documentation Ready Ready Preview Sep 14, 2026 2:31pm UTC
portable-text-example-basic Ready Ready Preview Sep 14, 2026 2:31pm UTC
portable-text-playground Ready Ready Preview Sep 14, 2026 2:31pm UTC

Request Review

@changeset-bot

changeset-bot Bot commented Sep 14, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: d3ac697

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@portabletext/markdown Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actions Bot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Bundle Stats

✅ No significant changes.

All scenario measurements (7)

🗺️ @portabletext/editor / @portabletext/editor · @portabletext/editor / @portabletext/editor/behaviors · @portabletext/editor / @portabletext/editor/plugins · @portabletext/editor / @portabletext/editor/selectors · @portabletext/editor / @portabletext/editor/traversal · @portabletext/editor / @portabletext/editor/utils · @portabletext/markdown / @portabletext/markdown · Artifacts

Scenario Kind Bundle (raw / gzip) Gzip change Import time Import change
⚪ @portabletext/editor / @portabletext/editor export 1.09 MB / 254.3 KB None 68 ms -2 ms, -2.8%
⚪ @portabletext/editor / @portabletext/editor/behaviors export 4.0 KB / 1.4 KB None 2 ms -0 ms, -2.9%
⚪ @portabletext/editor / @portabletext/editor/plugins export 5.1 KB / 1.8 KB None 7 ms -0 ms, -0.3%
⚪ @portabletext/editor / @portabletext/editor/selectors export 94.7 KB / 21.7 KB None 8 ms -0 ms, -1.9%
⚪ @portabletext/editor / @portabletext/editor/traversal export 42.8 KB / 11.2 KB None 6 ms -0 ms, -1.3%
⚪ @portabletext/editor / @portabletext/editor/utils export 33.8 KB / 9.1 KB None 6 ms -0 ms, -0.6%
⚪ @portabletext/markdown / @portabletext/markdown export 382.9 KB / 108.6 KB +430 B, +0.4% 45 ms +0 ms, +0.9%

Significant means at least 1.0 KB and 1% gzip, or at least 5 ms and 10% import time.

@christianhg
christianhg marked this pull request as ready for review September 14, 2026 14:38

This branch was successfully deployed

3 active deployments
Preview – portable-text-playground d3ac6976 Deployed Sep 14, 2026 by vercel[bot]
Preview – portable-text-editor-documentation d3ac6976 Deployed Sep 14, 2026 by vercel[bot]
Preview – portable-text-example-basic d3ac6976 Deployed Sep 14, 2026 by vercel[bot]
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