Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/inspector-mixed-children-passthrough.md
Original file line number Diff line number Diff line change
@@ -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.
37 changes: 37 additions & 0 deletions packages/core/src/editing/edit-ops.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -735,6 +735,43 @@ describe('applyEdit / set-text', () => {
expect(r.source).toContain('<div>{children}</div>');
});

it('falls through to call sites for a {children} slot mixed with other children', () => {
// The `<div>` 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 }) => (',
' <div><span>{label}</span>{children}</div>',
');',
'export default [() => (',
' <Row label="L">Hello</Row>',
')];',
'',
].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('<Row label="L">Goodbye</Row>');
expect(r.source).toContain('<div><span>{label}</span>{children}</div>');
});

it('routes a mixed-children prop pass-through to the matching call site', () => {
// The `<div>` 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 }) => (',
' <div>{children}{title}</div>',
');',
'export default [() => (',
' <Banner title="Hello">body</Banner>',
')];',
'',
].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('<Banner title="Goodbye">body</Banner>');
});

it('disambiguates between sibling call sites of a children-slot component', () => {
const src = [
'const Eyebrow = ({ children }) => (',
Expand Down
37 changes: 25 additions & 12 deletions packages/core/src/editing/edit-ops.ts
Original file line number Diff line number Diff line change
Expand Up @@ -787,12 +787,18 @@ function buildTextRangeStyleSplices(
// `<Wrap>{children}</Wrap>` and `<h2>{title}</h2>` — 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. `<div><span>{label}</span>{children}</div>`), 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 = {
Expand Down Expand Up @@ -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<string>();
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) {
Expand Down