From 38b6263c979446ee9fd203a21dce89fc852b10c2 Mon Sep 17 00:00:00 2001 From: yen0304 Date: Sat, 20 Jun 2026 14:22:51 +0800 Subject: [PATCH] fix(core): resolve inspector text edits for mixed-children pass-throughs collectElementTextCandidates only resolved a {children}/prop pass-through to its call sites when it was the host element's sole meaningful child (propPassthroughName bailed when meaningfulChildren(element).length !== 1). A host like
{label}{children}
therefore matched none of the fallbacks and the inspector reported "element has no editable text". Collect pass-through names from the element's direct children instead, so a {children} or prop pass-through mixed with other children still resolves to the call site. --- .../inspector-mixed-children-passthrough.md | 5 +++ packages/core/src/editing/edit-ops.test.ts | 37 +++++++++++++++++++ packages/core/src/editing/edit-ops.ts | 37 +++++++++++++------ 3 files changed, 67 insertions(+), 12 deletions(-) create mode 100644 .changeset/inspector-mixed-children-passthrough.md diff --git a/.changeset/inspector-mixed-children-passthrough.md b/.changeset/inspector-mixed-children-passthrough.md new file mode 100644 index 00000000..aaf00669 --- /dev/null +++ b/.changeset/inspector-mixed-children-passthrough.md @@ -0,0 +1,5 @@ +--- +"@open-slide/core": patch +--- + +Resolve inspector text edits for host elements that mix a `{children}` or prop pass-through with other children. diff --git a/packages/core/src/editing/edit-ops.test.ts b/packages/core/src/editing/edit-ops.test.ts index c607e20c..9a7aafcd 100644 --- a/packages/core/src/editing/edit-ops.test.ts +++ b/packages/core/src/editing/edit-ops.test.ts @@ -735,6 +735,43 @@ describe('applyEdit / set-text', () => { expect(r.source).toContain('
{children}
'); }); + it('falls through to call sites for a {children} slot mixed with other children', () => { + // The `
` mixes a nested `{label}` span with the `{children}` slot, so the + // `{children}` pass-through is not the element's only child. With no prevText the edit + // targets the host element directly, which must still resolve to the call site instead + // of failing with "no editable text". + const src = [ + 'const Row = ({ label, children }) => (', + '
{label}{children}
', + ');', + 'export default [() => (', + ' Hello', + ')];', + '', + ].join('\n'); + const r = applyEdit(src, 2, 2, [{ kind: 'set-text', value: 'Goodbye' }]); + if (!r.ok) throw new Error(`expected ok, got ${r.error}`); + expect(r.source).toContain('Goodbye'); + expect(r.source).toContain('
{label}{children}
'); + }); + + it('routes a mixed-children prop pass-through to the matching call site', () => { + // The `
` mixes a `{children}` slot with a `{title}` prop pass-through; editing the + // `{title}` text resolves to the `title="..."` literal at the call site. + const src = [ + 'const Banner = ({ title, children }: { title: string; children: unknown }) => (', + '
{children}{title}
', + ');', + 'export default [() => (', + ' body', + ')];', + '', + ].join('\n'); + const r = applyEdit(src, 2, 2, [{ kind: 'set-text', value: 'Goodbye', prevText: 'Hello' }]); + if (!r.ok) throw new Error(`expected ok, got ${r.error}`); + expect(r.source).toContain('body'); + }); + it('disambiguates between sibling call sites of a children-slot component', () => { const src = [ 'const Eyebrow = ({ children }) => (', diff --git a/packages/core/src/editing/edit-ops.ts b/packages/core/src/editing/edit-ops.ts index 2686a583..088a8585 100644 --- a/packages/core/src/editing/edit-ops.ts +++ b/packages/core/src/editing/edit-ops.ts @@ -787,12 +787,18 @@ function buildTextRangeStyleSplices( // `{children}` and `

{title}

` — sole child is a // JSXExpressionContainer wrapping a bare Identifier. Returns the identifier // name; callers branch on `'children'` vs. a generic prop passthrough. -function propPassthroughName(element: t.JSXElement): string | null { - const meaningful = meaningfulChildren(element); - if (meaningful.length !== 1) return null; - const child = meaningful[0]; - if (!t.isJSXExpressionContainer(child)) return null; - return t.isIdentifier(child.expression) ? child.expression.name : null; +// Names of `{prop}` / `{children}` pass-throughs among the element's *direct* children. +// A host element can mix a pass-through with other content +// (e.g. `
{label}{children}
`), so we don't require the pass-through to be +// the sole child — only that it is a direct child expression container wrapping a bare identifier. +function directPassthroughNames(element: t.JSXElement): string[] { + const names: string[] = []; + for (const child of element.children) { + if (t.isJSXExpressionContainer(child) && t.isIdentifier(child.expression)) { + names.push(child.expression.name); + } + } + return names; } type EnclosingComponent = { @@ -1028,12 +1034,19 @@ function collectElementTextCandidates(ast: t.File, element: t.JSXElement): TextC const candidates: TextCandidate[] = []; collectTextCandidates(element, candidates); if (candidates.length === 0) { - const passthrough = propPassthroughName(element); - const enclosing = passthrough ? findEnclosingComponent(ast, element) : null; - if (passthrough === 'children' && enclosing) { - candidates.push(...collectCallSiteCandidates(ast, enclosing.name)); - } else if (passthrough && enclosing && componentDestructuresProp(enclosing.fn, passthrough)) { - candidates.push(...collectPropCallSiteCandidates(ast, enclosing.name, passthrough)); + const passthroughs = directPassthroughNames(element); + const enclosing = passthroughs.length > 0 ? findEnclosingComponent(ast, element) : null; + if (enclosing) { + const seen = new Set(); + for (const passthrough of passthroughs) { + if (seen.has(passthrough)) continue; + seen.add(passthrough); + if (passthrough === 'children') { + candidates.push(...collectCallSiteCandidates(ast, enclosing.name)); + } else if (componentDestructuresProp(enclosing.fn, passthrough)) { + candidates.push(...collectPropCallSiteCandidates(ast, enclosing.name, passthrough)); + } + } } } if (candidates.length === 0) {