From a0bef54eaaaaf992f22fe80d4a34e9ab3e1e5629 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 07:42:40 +0000 Subject: [PATCH] docs(react): the InlineEditContextValue.draft comment teaches the own-key read The `draft` member's doc comment told hosts to read a field's live value with a nullish fallback to the saved record. A field the user or a cascade clear emptied is an own draft key with an empty value, so that read handed back the value just removed (the prune loop objectui#7190 measured on the highlights strip). The comment now teaches the staged-record spread: an own draft key wins even when empty, and the saved value is read only when the key is absent. No runtime change. Adds a source-text pin over the comment's code spans, in the pattern of the package's existing doc pins, and a patch changeset for @object-ui/react. Claude-Session: https://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNC Co-authored-by: Claude --- .../10466-inline-edit-draft-read-rule.md | 19 +++ .../react/src/context/InlineEditContext.tsx | 10 +- ...ineEditContext.draftReadRule-10466.test.ts | 135 ++++++++++++++++++ 3 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 .changeset/10466-inline-edit-draft-read-rule.md create mode 100644 packages/react/src/context/__tests__/InlineEditContext.draftReadRule-10466.test.ts diff --git a/.changeset/10466-inline-edit-draft-read-rule.md b/.changeset/10466-inline-edit-draft-read-rule.md new file mode 100644 index 0000000000..2ae94bc025 --- /dev/null +++ b/.changeset/10466-inline-edit-draft-read-rule.md @@ -0,0 +1,19 @@ +--- +'@object-ui/react': patch +--- + +Correct the read rule in the `InlineEditContextValue.draft` doc comment (objectui#10466). + +It told hosts to read a field's live value as `draft[name] ?? data[name]`. That read +falls back to the saved value when the draft value is `null` or `undefined`. A field the +user emptied is an own draft key with an empty value, and so is a single select or radio +emptied by a cascade clear, which stages `null` since objectui#10291. A host that followed +the comment showed the value the user had just removed, and handed it back to an option +widget that pruned it, which pruned it again on every render (the loop objectui#7190 fixed +on the highlights strip). + +The comment now teaches the own-key read: take the value from the staged record +`{ ...data, ...draft }`. An own draft key wins even when its value is empty +(`null`, `undefined`, `''`), and the saved value is read only when the key is absent. The +comment ships in `dist` typings and is what a host author sees on hover. No runtime +behaviour changes. A source-text pin keeps the comment from teaching a fallback read again. diff --git a/packages/react/src/context/InlineEditContext.tsx b/packages/react/src/context/InlineEditContext.tsx index 0239d80892..8457b82817 100644 --- a/packages/react/src/context/InlineEditContext.tsx +++ b/packages/react/src/context/InlineEditContext.tsx @@ -133,7 +133,15 @@ export interface InlineEditContextValue { /** * Draft of user-edited values. Holds ONLY the keys the user actually * changed, so the save path never writes computed / read-only / untouched - * fields. Read a field's live value as `draft[name] ?? data[name]`. + * fields. Read a field's live value from the staged record + * `{ ...data, ...draft }`, where `data` is the saved record: an OWN draft + * key wins even when its value is empty (`null`, `undefined`, `''`), and + * `data[name]` is read only when the key is absent. An own key with an + * empty value is a field the user, or a cascade clear, emptied, not an + * untouched one. A read that falls back to the saved value there shows the + * value the user just removed, and hands an option widget that pruned it + * the same value to prune again on every render (objectui#7190, + * objectui#10466). */ draft: Record; /** Field to auto-focus when edit was entered from a specific field. */ diff --git a/packages/react/src/context/__tests__/InlineEditContext.draftReadRule-10466.test.ts b/packages/react/src/context/__tests__/InlineEditContext.draftReadRule-10466.test.ts new file mode 100644 index 0000000000..570fa34e9a --- /dev/null +++ b/packages/react/src/context/__tests__/InlineEditContext.draftReadRule-10466.test.ts @@ -0,0 +1,135 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#10466 — the `draft` member's doc comment on the published + * `InlineEditContextValue` must teach a live-value read in which an OWN draft + * key wins even when its value is empty. + * + * ## The defect + * + * The doc comment prescribed a read that falls back to the saved value + * whenever the draft value is nullish. A field the user emptied, or a cascade + * clear emptied (it stages `null` since objectui#10291), is exactly an own + * draft key with an empty value. A host that copied the published sentence + * therefore showed, and handed its widgets, the value the user had just + * removed; on the record page's highlights strip that read looped an option + * widget's prune forever (objectui#7190). No in-tree host followed the + * sentence. The trap was for the next host author, human or AI, who copies it + * from the `.d.ts` hover. + * + * ## Why a source-text pin + * + * A doc comment is erased before anything runs, so no behavioural test can + * reach it. This takes the same narrow exception the sibling doc pins in this + * package take for prescriptive doc text (`LazyPluginLoader.jsdocExample`, + * `useNavigationOverlay.docExampleRecordSource-7638`). + * + * ## What it asserts, kept deliberately loose + * + * Only the READ RULE taught by the doc comment's code spans, never its prose, + * wording or ordering: + * 1. no code span reads the draft and then falls back through a nullish or + * falsy operator; + * 2. at least one code span names a read that keeps an own draft key. + * + * Both matchers are proven able to fire on synthetic spellings before any + * verdict on the real doc comment is believed. + */ + +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +/** The context under test, by SOURCE path: the doc comment lives here and ships in `dist` typings. */ +const SOURCE = readFileSync(join(HERE, '..', 'InlineEditContext.tsx'), 'utf8'); + +/** + * The doc comment directly above the `draft:` member of + * `InlineEditContextValue`, with its `*` gutters removed. Anchored on the + * interface so a `draft` member of any other type in the file is never read. + */ +function draftDocComment(): string { + const iface = SOURCE.indexOf('export interface InlineEditContextValue'); + if (iface === -1) throw new Error('`export interface InlineEditContextValue` not found; re-anchor this pin.'); + const ifaceEnd = SOURCE.indexOf('\n}', iface); + const member = SOURCE.indexOf('\n draft:', iface); + if (member === -1 || member > ifaceEnd) { + throw new Error('`draft:` member not found in `InlineEditContextValue`; re-anchor this pin.'); + } + const open = SOURCE.lastIndexOf('/**', member); + const close = SOURCE.indexOf('*/', open); + if (open < iface || close === -1 || close > member || SOURCE.slice(close + 2, member).trim() !== '') { + throw new Error('no doc comment directly above `draft:`; re-anchor this pin.'); + } + return SOURCE.slice(open + 3, close) + .split('\n') + .map((line) => line.replace(/^\s*\* ?/, '')) + .join('\n'); +} + +/** The backtick code spans of a block of prose, whitespace collapsed (a span may wrap a line). */ +function codeSpans(text: string): string[] { + return [...text.matchAll(/`([^`]+)`/g)].map((m) => m[1].replace(/\s+/g, ' ').trim()); +} + +/** A draft read followed by a fallback operator: it returns the saved value for an emptied field. */ +const FALLBACK_READ = /\bdraft\b.*(?:\?\?|\|\|)/; + +/** Reads that keep an own draft key even when its value is empty. */ +const OWN_KEY_READS: readonly RegExp[] = [ + // the staged-record spread, draft LAST so its own keys win + /\{\s*\.\.\.(?!draft\b)[\w.?]+\s*,\s*\.\.\.draft\s*\}/, + // an explicit own-key test + /\bin\s+draft\b/, + /\bhasOwn(?:Property\.call)?\(\s*draft\b/, +]; +const keepsOwnKey = (span: string): boolean => OWN_KEY_READS.some((re) => re.test(span)); + +describe('InlineEditContextValue.draft doc comment teaches the own-key read (objectui#10466)', () => { + const doc = draftDocComment(); + const spans = codeSpans(doc); + + it('LIT CONTROL: the doc comment was read and carries code spans', () => { + // Every verdict below is about these spans. An empty list would make + // "no fallback read" vacuously true. + expect(doc.trim().length).toBeGreaterThan(0); + expect(spans.length).toBeGreaterThan(0); + }); + + it('CONTROL: the fallback matcher fires on the read objectui#10466 removed, and only on fallbacks', () => { + expect(FALLBACK_READ.test('draft[name] ?? data[name]')).toBe(true); + expect(FALLBACK_READ.test('draft[name] || data[name]')).toBe(true); + expect(FALLBACK_READ.test('{ ...data, ...draft }')).toBe(false); + expect(FALLBACK_READ.test('name in draft ? draft[name] : data[name]')).toBe(false); + }); + + it('CONTROL: the own-key recogniser accepts own-key reads and refuses the rest', () => { + expect(keepsOwnKey('{ ...data, ...draft }')).toBe(true); + expect(keepsOwnKey('name in draft ? draft[name] : data[name]')).toBe(true); + expect(keepsOwnKey('Object.hasOwn(draft, name) ? draft[name] : data[name]')).toBe(true); + expect(keepsOwnKey('Object.prototype.hasOwnProperty.call(draft, name)')).toBe(true); + // the saved record spread last: the draft never wins + expect(keepsOwnKey('{ ...draft, ...data }')).toBe(false); + expect(keepsOwnKey('draft[name] ?? data[name]')).toBe(false); + }); + + it('teaches no read that falls back to the saved value on an empty draft value', () => { + expect( + spans.filter((span) => FALLBACK_READ.test(span)), + 'A host author copies this read. A fallback on an empty draft value returns the saved ' + + 'value for a field the user or a cascade clear emptied (objectui#7190, objectui#10466).', + ).toEqual([]); + }); + + it('names a read in which an own draft key wins', () => { + expect(spans.some(keepsOwnKey), `code spans read: ${JSON.stringify(spans)}`).toBe(true); + }); +});