Skip to content

fix(#1498, #1496): bind value-shaped controls, and decode the change event instead of the node - #1588

Merged
rbuergi merged 7 commits into
mainfrom
fix/clients-sdk
Aug 14, 2026
Merged

rbuergi merged 7 commits into
mainfrom
fix/clients-sdk

Conversation

@rbuergi

@rbuergi rbuergi commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

⚠️ Stacks behind #1585. The Clients workflow is red on main (the TypeScript 7 / React 19 bump), so this PR's own Clients run cannot go green until that restore lands. Merge #1585 first.

#1498 — value-shaped controls rendered an empty editor on RN

CodeEditorControl, MarkdownEditorControl and SearchBoxControl carry their text on Value (the Blazor views bind ViewModel.Value), but useField resolved control.data ?? control.isChecked. A control arriving from the server with value and no data therefore resolved to undefined and rendered an empty editor, with nothing on screen to say the text had simply not been read.

The precedence now lives in the shared helper — value ?? data ?? isChecked — rather than in the RN leaves. The web pack's monaco leaf already applies exactly that locally, and putting it in one place is what stops the two packs disagreeing again. It is a no-op for every Data-shaped control.

editors.test.tsx was building its tree with data: ptr(...) — the harness encoding the bug. It exercised the shape the pack happened to support instead of the shape the server sends, so an empty RN editor passed. The tree is now value-shaped for the three controls whose C# record names the member Value. Verified RED on the previous helper (2 failures), green after.

#1496 — the Node SDK decoded the EVENT as the NODE

clients/typescript's Mesh.watch yielded meshNodeFromChange(delivery.message). DataChangedEvent carries { changeType, change, streamId } and none of the four keys meshNodeFromChange reads, so every emission was { path: undefined, name: undefined, nodeType: undefined, content: {} }.

Both consequences were silent:

  • get() resolved to "the node exists but is empty" rather than erroring;
  • submitMessage read content["userMessageIds"] off that value, saw [], and sent a patch that replaced the thread's id list with a single id — dropping every earlier message under RFC 7396 array semantics.

The browser twin had the fold right all along, so the fix is to stop having two implementations. The fold moves into changeFold.ts, mirrored byte-identically into both SDKs alongside jsonPatch.ts — the same discipline wire.ts already uses for the request shapes — and wireContract.test.ts's drift guard now covers all three mirrored files.

The guard earned its keep immediately: it caught an edit I had made to only the grpc-web copy.

One defect found while extracting it: message["change"] ?? message["Change"] tested for undefined, which collapses ABSENT with NULL — an event carrying an explicit null Change read as "no change member" and the whole event was returned as the node. A membership test keeps them apart.

And why it survived: clients/typescript had no test for Mesh at all, and its CI job ran npm run typecheck only — never npm test, though the script and the runner were both already there. Both halves are fixed: a decode test in each SDK, and the node-sdk job now runs the tests.

Not in this PR

#1497 — three shells hand-rolling the same four /api/mesh fetches. It is a cross-shell refactor rather than a live defect, and folding it in would make this diff unreviewable. It follows as its own change.

Verification

package result
@meshweaver/client-web 63/63
@meshweaver/react 261/261
react-native 157/157
@meshweaver/client decode test 6/6

Fixes #1498, #1496

🤖 Generated with Claude Code

https://claude.ai/code/session_01KmBFaHTqhy7h742wkAaJKj

… event

#1498 — CodeEditorControl, MarkdownEditorControl and SearchBoxControl carry
their text on `Value` (the Blazor views bind ViewModel.Value), but `useField`
resolved `control.data ?? control.isChecked`. A control arriving from the server
with `value` and no `data` therefore resolved to undefined and rendered an EMPTY
editor on RN, with nothing on screen to say the text had simply not been read.

The precedence now lives in the SHARED helper — `value ?? data ?? isChecked` —
rather than in the RN leaves, because the web pack's monaco leaf already applies
exactly that locally and putting it in one place is what stops the two packs
disagreeing again. It is a no-op for every Data-shaped control.

editors.test.tsx was building its tree with `data: ptr(...)`, i.e. the harness
encoded the bug: it exercised the shape the pack happened to support instead of
the shape the server sends, so an empty RN editor passed. The tree is now
value-shaped for the three controls whose C# record names the member Value.
Verified RED on the previous helper (2 failures), green after.

#1496 — clients/typescript's Mesh.watch yielded meshNodeFromChange(delivery.
message): it decoded the EVENT as the NODE. DataChangedEvent carries
{ changeType, change, streamId } and none of the four keys meshNodeFromChange
reads, so every emission was { path: undefined, …, content: {} }. Both
consequences were silent — get() resolved to "the node exists but is empty"
instead of erroring, and submitMessage read content["userMessageIds"] off that,
saw [], and patched the thread's id list down to a single id, dropping every
earlier message under RFC 7396 array semantics.

The browser twin had the fold right all along, so the fix is to stop having two
implementations: the fold moves into changeFold.ts, mirrored BYTE-IDENTICALLY
into both SDKs alongside jsonPatch.ts — the same discipline wire.ts already uses
for the request shapes — and wireContract.test.ts's drift guard now covers all
three mirrored files. (The guard earned its keep immediately: it caught an edit
made to only the grpc-web copy.)

One defect found while extracting it: `message["change"] ?? message["Change"]`
tested for undefined, which collapses ABSENT with NULL — an event carrying an
explicit null Change read as "no change member" and the whole EVENT was returned
as the node. The membership test keeps them apart.

And the reason it survived: clients/typescript had no test for Mesh at all, and
its CI job ran `npm run typecheck` only — never `npm test`, though the script and
the runner were both already there. Both halves are fixed: a decode test in each
SDK, and the node-sdk job now runs the tests.

#1497 (three shells hand-rolling the same four /api/mesh fetches) is deliberately
NOT in here — it is a cross-shell refactor, not a live defect, and folding it in
would make this diff unreviewable. It follows as its own change.

Verified: grpc-web 63/63, react 261/261, react-native 157/157, node SDK decode
test 6/6.

Fixes #1498
Fixes #1496

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmBFaHTqhy7h742wkAaJKj
Copilot AI lite review requested due to automatic review settings August 14, 2026 15:29

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR fixes two client-side regressions across the React / React Native control packs and the TypeScript Node SDK: (1) properly binding controls whose payload is carried on value (not data), and (2) decoding DataChangedEvent deliveries by folding the event’s change into node state rather than treating the event envelope as the node.

Changes:

  • Update the shared useField binding precedence to value ?? data ?? isChecked, and adjust RN editor tests to exercise the server-sent value shape.
  • Introduce a shared, mirrored changeFold implementation (+ minimal RFC6902 patch applier) and wire both TypeScript SDKs to fold subscription events consistently.
  • Add decode regression tests and ensure the node-sdk CI job runs them.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
clients/react/src/controls/common.ts Fixes binding precedence so value-shaped controls resolve correctly.
clients/react-native/src/editors.test.tsx Updates the RN harness to use value for editor/search controls and adds a regression guard.
clients/typescript/src/mesh.ts Updates Mesh.watch to fold DataChangedEvent into node state before decoding.
clients/typescript/src/changeFold.ts Adds the shared change-fold implementation for the Node SDK (mirrored with grpc-web).
clients/typescript/src/jsonPatch.ts Adds a minimal RFC 6902 patch applier used by changeFold.
clients/typescript/test/changeFold.test.mjs Adds a Node SDK regression test ensuring event decoding and patch folding behave correctly.
clients/grpc-web/src/mesh.ts Switches grpc-web watch to the shared foldChange implementation.
clients/grpc-web/src/changeFold.ts Adds the shared change-fold implementation for grpc-web (mirrored with Node SDK).
clients/grpc-web/src/changeFold.test.ts Adds a grpc-web regression test for event decoding and patch folding.
clients/grpc-web/src/wireContract.test.ts Extends drift guard to enforce byte-identical mirrored modules (wire, jsonPatch, changeFold).
.github/workflows/clients.yml Ensures the node-sdk job runs tests (not just typecheck).

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread clients/typescript/src/changeFold.ts Outdated
Comment thread clients/grpc-web/src/changeFold.ts Outdated
@rbuergi

rbuergi commented Aug 14, 2026

Copy link
Copy Markdown
Contributor Author

Heads-up on an overlap, from the session fixing the red Clients workflow (#1583 + #1587) — no action needed from you yet, just so whoever merges second isn't surprised.

We independently made the same change. Both this PR and #1583 add - run: npm test to the @meshweaver/client job in .github/workflows/clients.yml, for the same reason you state so well — the package defined test and shipped test/worker.test.mjs, and the job ran typecheck only. Two workstreams found that gap within hours of each other, which is probably the strongest argument for #1568 (making Clients a required check).

Expect a textual conflict there, and note a naive resolution could end up running npm test twice in that job. #1583 also bumps that job to node-version: 22 in the same region.

Two ordering notes, because the toolchain moves under you:

Cleanest sequence is probably #1583 → #1587 → then merge main into this branch and re-run, so your new tests execute on the fixed toolchain rather than the broken one. Both of ours are locally verified and only waiting on their required check.

Happy to resolve the clients.yml conflict from our side instead if you'd rather — say the word.

@github-actions

github-actions Bot commented Aug 14, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 0)

728 tests   728 ✅  10m 37s ⏱️
 10 suites    0 💤
 10 files      0 ❌

Results for commit 90f0fd9.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 14, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 4)

1 936 tests   1 926 ✅  7m 20s ⏱️
   11 suites     10 💤
   11 files        0 ❌

Results for commit 90f0fd9.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 14, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 3)

   11 files     11 suites   6m 7s ⏱️
1 835 tests 1 732 ✅ 103 💤 0 ❌
2 184 runs  2 081 ✅ 103 💤 0 ❌

Results for commit 90f0fd9.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 14, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 5)

1 925 tests   1 733 ✅  6m 14s ⏱️
   11 suites    192 💤
   11 files        0 ❌

Results for commit 90f0fd9.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 14, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 1)

1 464 tests   1 464 ✅  6m 50s ⏱️
   11 suites      0 💤
   11 files        0 ❌

Results for commit 90f0fd9.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 14, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 2)

2 551 tests   2 550 ✅  8m 25s ⏱️
   12 suites      1 💤
   12 files        0 ❌

Results for commit 90f0fd9.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 14, 2026 •

Copy link
Copy Markdown
Contributor

Test Results

    66 files      66 suites   45m 37s ⏱️
10 439 tests 10 133 ✅ 306 💤 0 ❌
10 788 runs  10 482 ✅ 306 💤 0 ❌

Results for commit 90f0fd9.

♻️ This comment has been updated with latest results.

# Conflicts:
#	.github/workflows/clients.yml
@rbuergi

rbuergi commented Aug 14, 2026

Copy link
Copy Markdown
Contributor Author

CI red, attributed — neither failure is this diff, which touches only clients/** TypeScript and one line of clients.yml.

RN connector + RN web (Playwright) — inherited from main. Those are the two jobs #1587 fixes (react-native 0.76.5 hard-peers react ^18.2.0, so npm ci dies with ERESOLVE before anything runs). This PR will go green on them once #1587 lands; I'll merge main in then.

Clients workflow verdict — same cause, one level up: the gate is correctly reporting the red Clients run for this commit. Working as intended.

Shard 3 — MeshWeaver.Persistence.Test.DataContextIntegrationTest.Persistence_StoryContentIsPreserved — unreachable from this diff by construction. The change set is changeFold.ts, jsonPatch.ts, mesh.ts (both SDKs), common.ts, two test files and a workflow line; there is no C# in it at all, and no path by which a TypeScript decode helper reaches a .NET persistence test. Recent main runs have all six shards green, so this is not a standing main red either — it looks like the flake population #1384 describes. Not re-running it blind: it will re-run with the main merge for #1587, and if it recurs on that clean input it is worth its own issue with two data points rather than one.

Verified locally on the merged tree, against the amaro-based worker from #1583: clients/typescript npm ci ✅ typecheck ✅ npm test 11/11 ✅, grpc-web 63/63 ✅, react-native 157/157 ✅.

rbuergi and others added 3 commits August 14, 2026 18:40
Both symptoms are user-noticeable — an empty editor box in the mobile app, and a
conversation losing earlier messages through an app built on the Node client —
so they get a Fix entry rather than being skipped as internal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmBFaHTqhy7h742wkAaJKj
…tro can bundle it

CI caught a real one the local runs could not: the RN web export failed with
"Unable to resolve module ./changeFold.js from clients/grpc-web/src/mesh.ts".

grpc-web's source is bundled by METRO for the React Native web export, and Metro
does not remap a `.js` specifier to `.ts` the way TypeScript's own resolution
does. So the mirrored module had no import spelling that works everywhere:
`./jsonPatch` breaks the Node SDK (NodeNext REQUIRES the extension) and
`./jsonPatch.js` breaks the RN bundle. Three resolvers, no common spelling.

changeFold.ts is therefore import-free — it takes the RFC 6902 applier as a
PARAMETER, and each SDK passes its own applyJsonPatch. That is the same reason
wire.ts is import-free, and it keeps the byte-identical mirroring (and its drift
guard) intact, which an SDK-specific import would have made impossible. The
comment at the top now says so, with the failure named.

The importing files are not mirrored and differ as they always did: grpc-web
uses extensionless specifiers, the Node SDK uses `.js`.

Verified locally, per job: grpc-web 63/63 + typecheck; clients/typescript
typecheck + 11/11; react-native 157/157 + `expo export --platform web` (the
step that actually failed on CI) exporting 1.1 MB cleanly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmBFaHTqhy7h742wkAaJKj
@rbuergi

rbuergi commented Aug 14, 2026

Copy link
Copy Markdown
Contributor Author

CI caught a real defect the local runs could not, and it is worth naming because it is a three-resolver trap.

RN web (Playwright) failed with:

Error: Unable to resolve module ./changeFold.js from clients/grpc-web/src/mesh.ts

grpc-web's source is bundled by Metro for the React Native web export, and Metro does not remap a .js specifier to .ts the way TypeScript's own resolution does. So the mirrored module had no import spelling that works everywhere:

resolver ./jsonPatch ./jsonPatch.js
Node SDK (NodeNext) ✗ extension required ✓
grpc-web (Bundler) ✓ ✓
Metro (RN web export) ✓ ✗

Since the file has to be byte-identical in both SDKs for the drift guard, an SDK-specific import was not available either.

changeFold.ts is therefore import-free — it takes the RFC 6902 applier as a parameter and each SDK passes its own applyJsonPatch. That is precisely why wire.ts is import-free, and it keeps the mirroring (and its guard) intact. The comment at the top of the file now says so, with this failure named, so the next person does not reintroduce the import.

The importing files are not mirrored and differ as they always did: grpc-web extensionless, Node SDK .js.

Re-verified locally, per job, including the one that failed:

@meshweaver/client-web typecheck ✅ · 63/63 ✅
@meshweaver/client typecheck ✅ · 11/11 ✅
RN connector 157/157 ✅
RN web expo export --platform web ✅ (1.1 MB bundle, 457 modules)

The Clients workflow verdict failure was the same root — it was reporting that red.

Copilot review on #1588: the byte-identical file named ONE package's path
("mirrored into clients/typescript/src/changeFold.ts"), which is wrong when read
in that very copy — there the sentence describes the file you are already in.

Reworded to "the OTHER TypeScript SDK's copy of the same path", with the reason
stated, so the text stays true in both copies. The byte-identity requirement is
exactly what makes a path-naming comment a defect rather than a nitpick.

Verified: grpc-web 63/63, clients/typescript 11/11, and the two copies still
compare byte-identical (the drift guard's own assertion).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmBFaHTqhy7h742wkAaJKj
@rbuergi

rbuergi commented Aug 14, 2026

Copy link
Copy Markdown
Contributor Author

Review comment applied — and it is a real defect in a byte-identical file, not a nitpick.

The mirror note named ONE package's path (mirrored into clients/typescript/src/changeFold.ts), which is wrong precisely in that copy: there the sentence describes the file you are already reading. Byte-identity is what turns a path-naming comment into a bug — any sentence in this file has to be true on both sides.

Reworded to "the OTHER TypeScript SDK's copy of the same path", with the reason stated inline so it is not re-specialised later.

Verified: grpc-web 63/63, clients/typescript 11/11, and the two copies still compare byte-identical — which is the drift guard's own assertion.

Note for later, out of scope here: wire.ts carries the same one-sided phrasing ("mirrored BYTE-IDENTICALLY into clients/typescript/src/wire.ts") and has the same property, so it is worth the same one-line treatment when someone next touches it.

@rbuergi

rbuergi commented Aug 14, 2026

Copy link
Copy Markdown
Contributor Author

Merged main in — this is the sanctioned case for doing so, not a catch-up.

The shard-0 red was InstanceSyncPushTest.A_pending_content_change_pushes_a_DELETE_when_the_local_node_is_being_deleted, which is my regression from #1598 (I rewrote WaitForRemote to subscribe to a new FakeRemoteMesh.Changed source instead of polling, and it reddened main on two consecutive runs). It is reverted in #1605, now merged.

A pull_request run tests refs/pull/N/merge as computed when the run started, so re-running the failed job alone would re-sample the OLD merge commit — the one that still contains the defect. Merging the fixed main in is what gets a tree that actually has the fix. That is AGENTS.md's second case verbatim: "CI fails on something your diff does not touch — merge main BEFORE investigating; a stale branch re-samples flakes main has ALREADY fixed."

Revert-check done: git diff origin/main...HEAD --stat shows only my 12 intended files, no negative delta.

Everything else on the previous run was green, including all four client jobs and the RN web (Playwright) job that caught the Metro resolution defect.

@rbuergi
rbuergi merged commit a3fada0 into main Aug 14, 2026
30 checks passed
@rbuergi
rbuergi deleted the fix/clients-sdk branch August 19, 2026 10:30
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.

RN editors read control.data, but CodeEditorControl/MarkdownEditorControl carry their text on Value — a server-sent editor renders empty

2 participants