Skip to content

enhance(core): Check set() Union rows per member - #4179

Merged
ntucker merged 7 commits into
masterfrom
claude/project-thread-ii70fb
Oct 5, 2026
Merged

ntucker merged 7 commits into
masterfrom
claude/project-thread-ii70fb

Conversation

@ntucker

@ntucker ntucker commented Oct 5, 2026 •

Copy link
Copy Markdown
Collaborator

Requested by Nathaniel · project thread

Follow-up to #4133 (unreleased typed set() values).

Motivation

#4133 merged a Union's members into one row type to speed up type checking. That made prev => ({ ...prev, field }) updaters on large Unions about 3x slower than v0.18, and it let rows mix fields from different members. The per-member rows it replaced weren't stricter: their { [k: string]: unknown } catch-all let a row fall through to another member's row.

const Feed = new schema.Union({ post: Post, comment: Comment }, 'type');
// TypeScript error: commentBody is a Comment field, not a Post field
ctrl.set(Feed, { id: '1', type: 'post' }, { type: 'post', commentBody: 'hi' });

Solution

Each Union member gets its own row, with no index signature. When each member declares its discriminator as a literal (readonly type = 'post'), TypeScript picks the member by that discriminator and checks the row against it. A Union row now takes only declared fields, like an Entity row. A key that a schemaAttribute function reads must therefore be declared on each member (decided: strict rows over keeping the catch-all).

TS 6 check time, 1000 calls on a 30-member Union (master, then this PR):

Shape master this PR
prev => ({ ...prev, f }) 9.4s 3.3s (v0.18: 3.1s)
prev => ({ id: prev.id, f }) 2.3s 2.3s
() => ({ f }) 1.8s 1.7s
plain value 1.21s 0.91s

Zero loss: with @ts-expect-error neutralized, every error master reports still appears on TS 4.0, 4.5, 5.0, 5.4, 6 and 7. The new errors are the mixed-member and unknown-field rows. The v0.19 blog perf rows are re-measured against v0.18.1. The type-check budget is updated: union instantiations go up 19% from the per-member rows, but its check time doesn't change. setValues drops 71% and setUpdaters 49%.

🤖 Generated with Claude Code

https://claude.ai/code/session_01GzkTc2gisCbeWKi5F3v472


Note

Medium Risk
Compile-time-only change that may surface new TypeScript errors on Union set() calls; runtime store behavior is unchanged.

Overview
Tightens Controller.set() typing for Union schemas by making SetRow distributive over union members instead of merging all members into one permissive row. When each member uses a literal discriminator (e.g. readonly type = 'post'), TypeScript checks the value against the selected member only—mixing another member’s fields or unknown keys becomes a compile error, and keys used by schemaAttribute must be declared on each member.

Docs (Controller references, changeset, v0.19 blog) are updated to describe this behavior and refreshed perf numbers: set() plain values and updater functions on large unions are faster than the previous merged-row approach, with updated scripts/typeperf/budget.json instantiations. React hook tests add @ts-expect-error coverage for the new union rules.

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

A Union row is now checked against the member its discriminator selects,
without the index signature that let a row fall through to another member.
Spread updaters on a 30-member Union check about 3x faster than before
(TS 6: 9.4s to 3.3s per 1000 calls), and rows that mix members' fields or
carry unknown keys are now errors.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GzkTc2gisCbeWKi5F3v472
@changeset-bot

changeset-bot Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 01577f0

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes changesets to release 7 packages
Name Type
@data-client/core Patch
@data-client/react Patch
@data-client/vue Patch
example-benchmark Patch
example-benchmark-react Patch
test-bundlesize Patch
coinbase-lite Patch

Click here to learn what changesets are, and how to add one.

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

@vercel

vercel Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

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

Project Deployment Actions Updated
docs-site Ready Ready Preview Oct 5, 2026 5:56pm UTC

Request Review

@ntucker ntucker self-assigned this Oct 5, 2026
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GzkTc2gisCbeWKi5F3v472

ntucker commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator Author

Staff engineer (Cursor agent): LGTM on direction at f919dcc, FOLLOW_UP only, nothing here needs to change before merge.

This is a net simplification: SetItem and the FieldKeys/MemberField merge go away, SetRow just distributes, and the type gets both stricter and faster on the updater path. SetManyValue using SetRow directly is fine since E is already known not to be a union there.

Open question: I'd keep it strict (this PR) rather than restore the { [k: string]: unknown } catch-all. The catch-all is what let rows fall through to the wrong member, and declaring a discriminator the schemaAttribute function reads on the Entity is the same rule Entity rows already follow. Since #4133 is unreleased and v0.18 didn't type set() values at all, nobody loses anything that shipped.

FOLLOW_UP (docs nuance, take it or leave it): per-member checking only kicks in when the discriminator field has a literal type on each member, like the fixtures' readonly type = 'first'. If a user writes type = 'post' (no readonly) or type: string, TypeScript can't narrow, falls back to "the key exists on some member", and { type: 'post', commentBody: 'hi' } compiles again. The new notes in Controller.md, the skill references and the changeset read as if that always errors. One clause such as "when each member declares its discriminator as a literal (readonly type = 'post')" would make the claim exact, either here if docs get touched again or in a later docs pass.

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

ntucker commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator Author

Folded the docs nuance into this PR in cdf135d. I confirmed it first: with type = 'a' (no readonly), { type: 'a', bOnly: 1 } compiles. Controller.md, the changeset, the blog and the skill references now say per-member checking applies when each member declares its discriminator as a literal.


Generated by Claude Code

@github-actions

github-actions Bot commented Oct 5, 2026

Copy link
Copy Markdown
Contributor

Size Change: 0 B

Total Size: 103 kB

ℹ️ View Unchanged
Filename Size
examples/test-bundlesize/dist/App.js 1.46 kB
examples/test-bundlesize/dist/polyfill.js 307 B
examples/test-bundlesize/dist/rdcClient.js 10.9 kB
examples/test-bundlesize/dist/rdcEndpoint.js 8.07 kB
examples/test-bundlesize/dist/rdcNextjs.js 12.3 kB
examples/test-bundlesize/dist/rdcPipeableStream.js 9.64 kB
examples/test-bundlesize/dist/react.js 59.7 kB
examples/test-bundlesize/dist/webpack-runtime.js 784 B

compressed-size-action

@github-actions github-actions Bot 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.

Benchmark React

Details
Benchmark suite Current: 01577f0 Previous: eb8aacd Ratio
data-client: getlist-100 143.89 ops/s (± 5.3%) 132.46 ops/s (± 4.8%) 0.92
data-client: getlist-500 44.05 ops/s (± 4.9%) 43.29 ops/s (± 5.0%) 0.98
data-client: update-entity 384.62 ops/s (± 11.0%) 322.58 ops/s (± 7.5%) 0.84
data-client: update-user 408.33 ops/s (± 11.5%) 333.33 ops/s (± 8.6%) 0.82
data-client: getlist-500-sorted 41.15 ops/s (± 9.3%) 44.64 ops/s (± 9.3%) 1.08
data-client: update-entity-sorted 370.37 ops/s (± 7.4%) 303.03 ops/s (± 6.6%) 0.82
data-client: update-entity-multi-view 370.37 ops/s (± 7.5%) 357.14 ops/s (± 9.9%) 0.96
data-client: list-detail-switch-10 10.31 ops/s (± 9.0%) 9.23 ops/s (± 8.8%) 0.90
data-client: update-user-10000 80.01 ops/s (± 14.7%) 77.52 ops/s (± 14.7%) 0.97
data-client: invalidate-and-resolve 38.31 ops/s (± 6.1%) 34.42 ops/s (± 3.7%) 0.90
data-client: unshift-item 224.75 ops/s (± 9.2%) 194.19 ops/s (± 7.9%) 0.86
data-client: delete-item 294.12 ops/s (± 6.7%) 243.9 ops/s (± 2.5%) 0.83
data-client: move-item 183.5 ops/s (± 10.1%) 166.67 ops/s (± 9.3%) 0.91

This comment was automatically generated by workflow using github-action-benchmark.

@github-actions github-actions Bot 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.

Benchmark

Details
Benchmark suite Current: 01577f0 Previous: eb8aacd Ratio
normalizeLong 418 ops/sec (±3.90%) 410 ops/sec (±3.15%) 0.98
normalizeLong Values 385 ops/sec (±1.24%) 393 ops/sec (±0.48%) 1.02
normalizeLong Scalar 349 ops/sec (±3.21%) 353 ops/sec (±3.42%) 1.01
normalizeLong Scalar update 915 ops/sec (±0.17%) 896 ops/sec (±0.90%) 0.98
denormalizeLong 242 ops/sec (±6.44%) 226 ops/sec (±6.16%) 0.93
denormalizeLong Values 224 ops/sec (±5.22%) 214 ops/sec (±5.18%) 0.96
denormalizeLong donotcache 1005 ops/sec (±0.97%) 984 ops/sec (±0.59%) 0.98
denormalizeLong Values donotcache 746 ops/sec (±0.28%) 731 ops/sec (±0.26%) 0.98
denormalizeLong Scalar donotcache 1052 ops/sec (±0.22%) 1005 ops/sec (±0.42%) 0.96
denormalizeShort donotcache 500x 1400 ops/sec (±0.15%) 1397 ops/sec (±0.14%) 1.00
denormalizeShort 500x 606 ops/sec (±6.14%) 629 ops/sec (±6.39%) 1.04
denormalizeShort 500x withCache 6837 ops/sec (±0.13%) 6684 ops/sec (±0.32%) 0.98
queryShort 500x withCache 3173 ops/sec (±0.08%) 3083 ops/sec (±0.59%) 0.97
buildQueryKey All 58819 ops/sec (±1.48%) 57185 ops/sec (±0.91%) 0.97
query All withCache 6342 ops/sec (±2.69%) 5928 ops/sec (±2.01%) 0.93
denormalizeLong with mixin Entity 220 ops/sec (±7.81%) 203 ops/sec (±6.10%) 0.92
denormalizeLong withCache 7754 ops/sec (±0.18%) 6654 ops/sec (±0.29%) 0.86
denormalizeLong withCache (Scalar churn) 7682 ops/sec (±1.22%) 6500 ops/sec (±0.74%) 0.85
denormalizeLong Values withCache 5251 ops/sec (±1.74%) 4766 ops/sec (±1.90%) 0.91
denormalizeLong Scalar withCache 7953 ops/sec (±0.40%) 7050 ops/sec (±0.26%) 0.89
denormalizeLong Scalar update withCache 4098 ops/sec (±1.07%) 4028 ops/sec (±0.23%) 0.98
denormalizeLong All withCache 6604 ops/sec (±0.20%) 6216 ops/sec (±0.39%) 0.94
denormalizeLong Query-sorted withCache 6635 ops/sec (±1.98%) 6060 ops/sec (±1.43%) 0.91
denormalizeLongAndShort withEntityCacheOnly 1834 ops/sec (±0.23%) 1684 ops/sec (±0.31%) 0.92
denormalize bidirectional 50 4708 ops/sec (±8.90%) 4350 ops/sec (±11.67%) 0.92
denormalize bidirectional 50 donotcache 41693 ops/sec (±0.27%) 43258 ops/sec (±0.58%) 1.04
getResponse 4462 ops/sec (±3.96%) 4393 ops/sec (±3.68%) 0.98
getResponse (null) 10265517 ops/sec (±0.56%) 9494914 ops/sec (±1.41%) 0.92
getResponse (clear cache) 208 ops/sec (±7.98%) 190 ops/sec (±9.48%) 0.91
getSmallResponse 3544 ops/sec (±0.45%) 3361 ops/sec (±1.09%) 0.95
getSmallInferredResponse 2894 ops/sec (±0.23%) 2841 ops/sec (±0.53%) 0.98
getResponse Collection 4226 ops/sec (±4.75%) 4463 ops/sec (±3.49%) 1.06
get Collection 2695 ops/sec (±0.20%) 2791 ops/sec (±0.37%) 1.04
get Query-sorted 5123 ops/sec (±1.57%) 4921 ops/sec (±1.86%) 0.96
setLong 430 ops/sec (±0.36%) 435 ops/sec (±0.26%) 1.01
setLongWithMerge 253 ops/sec (±0.15%) 238 ops/sec (±1.29%) 0.94
setLongWithSimpleMerge 267 ops/sec (±0.57%) 264 ops/sec (±0.78%) 0.99
setSmallResponse 500x 911 ops/sec (±1.65%) 895 ops/sec (±1.64%) 0.98
setMany 50x one-per-row 146 ops/sec (±0.47%) 141 ops/sec (±0.49%) 0.97
setMany 50 batch 3563 ops/sec (±1.02%) 3415 ops/sec (±2.44%) 0.96
setMany 500x one-per-row 14.93 ops/sec (±0.66%) 14.24 ops/sec (±0.70%) 0.95
setMany 500 batch 1400 ops/sec (±0.62%) 1332 ops/sec (±3.14%) 0.95

This comment was automatically generated by workflow using github-action-benchmark.

@ntucker
ntucker marked this pull request as ready for review October 5, 2026 07:56
@codecov

codecov Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.08%. Comparing base (711c741) to head (01577f0).
⚠️ Report is 6 commits behind head on master.

Additional details and impacted files
@@           Coverage Diff           @@
##           master    #4179   +/-   ##
=======================================
  Coverage   98.08%   98.08%           
=======================================
  Files         165      165           
  Lines        3139     3139           
  Branches      625      625           
=======================================
  Hits         3079     3079           
  Misses         18       18           
  Partials       42       42           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GzkTc2gisCbeWKi5F3v472
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GzkTc2gisCbeWKi5F3v472
@ntucker
ntucker merged commit 10e15cc into master Oct 5, 2026
30 checks passed
@ntucker
ntucker deleted the claude/project-thread-ii70fb branch October 5, 2026 18:00
ntucker pushed a commit that referenced this pull request Oct 5, 2026
…ion rows

#4179 changed set() row types after #4189 started checking that the
playground editor types are current, so the editor-types check is red on
master.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EcBpA1YWwvXC94QHnzQG1H
ntucker added a commit that referenced this pull request Oct 5, 2026
#4179 changed set() row types after #4189 added the editor-types
check, so master's committed core.d.ts was stale.


Claude-Session: https://claude.ai/code/session_01N2JUxvTqcHCdBX8Xi1nQq6

Co-authored-by: Claude <noreply@anthropic.com>
ntucker added a commit that referenced this pull request Oct 5, 2026
#4181)

* docs: Generate DevTools MCP and Vue testing skill references from docs

The devtools-debugging skill reference (copied byte for byte into the React and
Vue skills) and vue-test-utilities (a copy of packages/vue/src/test/README.md)
were hand-written, so the build:skills drift check never covered them.

- Move the DevTools MCP recipe to docs/core/guides/devtools-debugging.md
  (framework-aware) and generate both skills' copies from it
- Add Vue "Unit testing components" (mountDataClient) and "Unit testing
  composables" (renderDataCompose) guides as .vue.md overrides of the React
  pages, and generate the vue-testing skill's references from them
- Fix renderDataCompose examples in the vue-testing skill: it is async and
  `result` is the composable's return value (there is no `result.current`)
- Point the test README and JSDoc @see links at the new guides

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

* docs: Simplify Vue testing skill and guides; link Vue testing guides

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

* docs: Link DevTools MCP debugging from Agent Skills

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

* internal(website): Regenerate playground core types for per-member Union rows

#4179 changed set() row types after #4189 started checking that the
playground editor types are current, so the editor-types check is red on
master.

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

* docs: Simplify agent debugging links and Vue testing skill

Move the DevTools MCP page into getting-started (it is listed there), link
it from the framework skill bullets instead of a second "Debugging with
agents" section, link DataClientPlugin to its API page, and drop examples
the Vue testing skill repeated.

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

* docs(vue): Serve composables testing guide at /vue/guides/unit-testing-composables

The Vue page keeps its unit-testing-hooks.vue.md filename so it shares the
React page's doc id (sidebar and framework switcher), and sets a slug so its
URL says composables. routeOf now honors absolute slugs so generated skill
references link to the same URL.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>

This branch was successfully deployed

1 active deployment
Preview — 01577f07 Deployed Oct 5, 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.

2 participants