From f0adb3c2718b93fb17ef0473fce4024057824d13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Thu, 21 May 2026 23:03:01 +0200 Subject: [PATCH 1/9] resolve re-yielded block-param curried sub-components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `` where the parent re-yields `{{yield (hash Legend=F.Legend)}}` from a nested `` did not resolve: resolveBinding treated `F.Legend` as a bare name (`resolveByName('F')`), ignoring the `.Legend` tail and that `F` is a block param. So it fell back to the binder's `
` Element type (cross-package) or transparent (local) instead of `` — the substituted `
` looked legend-less and wcag/h71 ("fieldset must have a legend as first child") FP-fired ~74x across HDS, plus ~17 fieldset element-permitted-content FPs. Add resolveBlockParamReyield: find the `` that introduces the block param, resolve Binder's source, and recurse into ITS yielded hash key. Single-level yield-hash already worked; this handles the multi-level re-yield (HdsFormCheckboxGroup → HdsFormFieldset → legend). Verified on real HDS: , h71 no longer fires. --- lib/resolver/walk.ts | 77 +++++++++++++++++++ .../glint-fixtures/fieldset-group-reyield.gts | 30 ++++++++ test/glint.test.ts | 19 +++++ 3 files changed, 126 insertions(+) create mode 100644 test/glint-fixtures/fieldset-group-reyield.gts diff --git a/lib/resolver/walk.ts b/lib/resolver/walk.ts index a613a7b..c719bfc 100644 --- a/lib/resolver/walk.ts +++ b/lib/resolver/walk.ts @@ -904,6 +904,76 @@ export function resolveYieldHashBinding(opts: YieldHashBindingOptions): Resoluti }); } +// Resolve a re-yielded block-param hash entry: `{{yield (hash +// Legend=F.Legend)}}` where `F` is a block param from `` +// in this same template. The yielded sub-component is whatever `Binder` +// itself yields under `Legend`, so resolve `Binder`'s source and recurse +// into its yield-hash. Mirrors HDS's `HdsFormCheckboxGroup` re-yielding +// `HdsFormFieldset`'s `F.Legend` — without this `` fell back to +// the binder's `
` Element type and FP-fired `wcag/h71`. +function resolveBlockParamReyield( + paramName: string, + hashKey: string, + parentSource: TemplateSource, + options: ResolveOptions, +): Resolution { + const depth = options.depth ?? 0; + if (depth >= MAX_DEPTH) return TRANSPARENT; + let ast: AST.Template; + try { + ast = parseTemplate(parentSource.content); + } catch { + return TRANSPARENT; + } + const binderTag = findBlockParamBinder(ast, paramName); + if (!binderTag) return TRANSPARENT; + + const importedFile = resolveImport(parentSource.origin, binderTag, options.ts ?? null); + let binderSource: TemplateSource | null = importedFile + ? findTemplateSource({ declFile: importedFile, ts: options.ts ?? null }) + : null; + if (!binderSource) { + binderSource = findTemplateSource({ + declFile: parentSource.origin, + componentName: binderTag, + ts: options.ts ?? null, + }); + } + if (!binderSource) return TRANSPARENT; + + return resolveYieldHashBinding({ + parentSource: binderSource, + hashKey, + parentArgs: options.consumerArgs ?? new Map(), + ts: options.ts ?? null, + visited: options.visited, + depth: depth + 1, + }); +} + +// Find the PascalCase element that introduces block param `paramName` +// via ``, returning its tag name. +function findBlockParamBinder(ast: AST.Template, paramName: string): string | null { + let result: string | null = null; + function visit(node: AST.Node): void { + if (result) return; + if (node.type === 'ElementNode') { + if (/^[A-Z]/.test(node.tag) && node.blockParams.includes(paramName)) { + result = node.tag; + return; + } + for (const child of node.children) visit(child); + } else if (node.type === 'BlockStatement') { + for (const child of node.program.body) visit(child); + if (node.inverse) for (const child of node.inverse.body) visit(child); + } else if (node.type === 'Template') { + for (const child of node.body) visit(child); + } + } + visit(ast); + return result; +} + // Like `resolveYieldHashBinding` but returns the underlying // `TemplateSource` (plus any curried `@arg` additions from a // `(component Inner …)` wrapper) instead of the leaf `Resolution`. @@ -1061,6 +1131,13 @@ function resolveBinding( if (!expr.head) return TRANSPARENT; if (expr.head.type === 'VarHead') { + // `F.Legend` — `F` is a block param introduced by `` + // in THIS template, so the yielded sub-component is whatever Binder + // re-yields under `Legend`. (A bare `Foo` with no tail is a local + // import / in-scope component → resolveByName.) + if (expr.tail.length > 0) { + return resolveBlockParamReyield(expr.head.name, expr.tail[0]!, parentSource, options); + } return resolveByName(expr.head.name, parentSource, options); } diff --git a/test/glint-fixtures/fieldset-group-reyield.gts b/test/glint-fixtures/fieldset-group-reyield.gts new file mode 100644 index 0000000..2de1511 --- /dev/null +++ b/test/glint-fixtures/fieldset-group-reyield.gts @@ -0,0 +1,30 @@ +import Component from '@glimmer/component'; +import { hash } from '@ember/helper'; +import type { TOC } from '@ember/component/template-only'; + +interface LegendSig { Element: HTMLLegendElement; Blocks: { default: [] } } +const Legend: TOC = ; + +interface InnerSig { Element: HTMLFieldSetElement; Blocks: { default: [{ Legend: typeof Legend }] } } +class InnerFieldset extends Component { + Legend = Legend; + +} + +// Re-yields F.Legend (the nested fieldset's yielded sub-component) — +// mirrors HdsFormCheckboxGroup re-yielding HdsFormFieldset's F.Legend. +interface OuterSig { Element: HTMLFieldSetElement; Blocks: { default: [{ Legend: typeof Legend }] } } +class OuterGroup extends Component { + +} + + diff --git a/test/glint.test.ts b/test/glint.test.ts index 7299af7..01c8f76 100644 --- a/test/glint.test.ts +++ b/test/glint.test.ts @@ -397,6 +397,25 @@ describe('Glint integration: cross-file .gts type resolution', () => { ).toBeDefined(); }); + it('resolves a RE-YIELDED curried sub-component through the chain (group → fieldset → legend)', () => { + // HdsFormCheckboxGroup shape: the group renders `` and re-yields `{{yield (hash Legend=F.Legend)}}`, so + // `` chains through the nested fieldset's yielded Legend to + // ``. Single-level yield-hash (yielded-curried-component.gts) + // already resolves; this multi-level RE-YIELD did not — `` + // fell back to the binder's `
` Element type (cross-package) + // / transparent (local) instead of ``, so the substituted + // `
` looked legend-less and `wcag/h71` ("fieldset must have + // a legend as first child") FP-fired ~74× across HDS. + const { filename, contents } = readFixture('fieldset-group-reyield.gts'); + const { componentTagMap } = extractAttrTypeMap(filename, contents)!; + const tags = [...componentTagMap.values()]; + expect( + tags.includes('legend'), + ` must resolve to through the re-yield chain; got: ${JSON.stringify([...componentTagMap.entries()])}`, + ).toBe(true); + }); + it('does not crash when the imported .gts does not exist', () => { // Negative-path: the shim's path-existence check has to fail gracefully // rather than throwing. broken-import.gts imports './does-not-exist.gts' From 8d1d5a840d815c3dd503f2cb57c6424e9d15eee5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Thu, 21 May 2026 23:35:48 +0200 Subject: [PATCH 2/9] re-yield: pass binder's own @args; restrict binder to PascalCase Two fixes from Copilot review on #45: - resolveBlockParamReyield passed the OUTER component's consumerArgs as parentArgs when recursing into the binder. The binder is invoked here (``), so @arg-driven yield-hash entries must see the args passed at THIS site. Collect the binder element's literal @arg="lit" and @arg={{@caller}} passthrough instead. - findBlockParamBinder matched any `/^[A-Z]/` tag, including dotted (`F.Foo`) / colon tags that resolveImport/by-name can't resolve (only yielding a useless TRANSPARENT). Restrict to `/^[A-Z][A-Za-z0-9]*$/` and return the element node (needed for the @arg collection above). --- lib/resolver/walk.ts | 43 ++++++++++++++++++++++++++++++++++--------- 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/lib/resolver/walk.ts b/lib/resolver/walk.ts index c719bfc..82fadb0 100644 --- a/lib/resolver/walk.ts +++ b/lib/resolver/walk.ts @@ -925,8 +925,30 @@ function resolveBlockParamReyield( } catch { return TRANSPARENT; } - const binderTag = findBlockParamBinder(ast, paramName); - if (!binderTag) return TRANSPARENT; + const binderNode = findBlockParamBinder(ast, paramName); + if (!binderNode) return TRANSPARENT; + const binderTag = binderNode.tag; + + // The binder is invoked WITHIN this template (``), + // so any `@arg`-driven yield-hash entry inside Binder must resolve + // against the args passed HERE — not the outer component's consumer + // args. Collect literal `@arg="lit"` and `@arg={{@caller}}` passthrough. + const binderArgs = new Map(); + for (const attr of binderNode.attributes) { + if (!attr.name.startsWith('@')) continue; + const argName = attr.name.slice(1); + if (attr.value.type === 'TextNode') { + binderArgs.set(argName, attr.value.chars); + } else if ( + attr.value.type === 'MustacheStatement' + && attr.value.path.type === 'PathExpression' + && attr.value.path.head?.type === 'AtHead' + ) { + const caller = attr.value.path.head.name.replace(/^@/, ''); + const v = options.consumerArgs?.get(caller); + if (v !== undefined) binderArgs.set(argName, v); + } + } const importedFile = resolveImport(parentSource.origin, binderTag, options.ts ?? null); let binderSource: TemplateSource | null = importedFile @@ -944,22 +966,25 @@ function resolveBlockParamReyield( return resolveYieldHashBinding({ parentSource: binderSource, hashKey, - parentArgs: options.consumerArgs ?? new Map(), + parentArgs: binderArgs, ts: options.ts ?? null, visited: options.visited, depth: depth + 1, }); } -// Find the PascalCase element that introduces block param `paramName` -// via ``, returning its tag name. -function findBlockParamBinder(ast: AST.Template, paramName: string): string | null { - let result: string | null = null; +// Find the element that introduces block param `paramName` via +// ``, returning the element node. Restricted to +// resolvable PascalCase tags (`/^[A-Z][A-Za-z0-9]*$/`): dotted (`F.Foo`) +// and colon (`:slot`) tags can't be resolved via import/by-name, so +// matching them would only yield a useless TRANSPARENT. +function findBlockParamBinder(ast: AST.Template, paramName: string): AST.ElementNode | null { + let result: AST.ElementNode | null = null; function visit(node: AST.Node): void { if (result) return; if (node.type === 'ElementNode') { - if (/^[A-Z]/.test(node.tag) && node.blockParams.includes(paramName)) { - result = node.tag; + if (/^[A-Z][A-Za-z0-9]*$/.test(node.tag) && node.blockParams.includes(paramName)) { + result = node; return; } for (const child of node.children) visit(child); From fc051ba68e6776982a04d3c41d37846b3d77d2aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Thu, 21 May 2026 23:46:43 +0200 Subject: [PATCH 3/9] re-yield: broaden binder lookup; fall back to resolveByName for non-block-param dotted bindings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolveBlockParamReyield now mirrors resolvePascalRecursion's full lookup order (import, same-file decl, by-name/classic, sibling probe) so re-yield chains resolve under .hbs and sibling-file consumers too. It also returns null when the head is not a block param introduced in this template, letting resolveBinding fall back to resolveByName(head) — restoring pre-re-yield behavior for plain VarHead+tail property access instead of bailing TRANSPARENT. --- lib/resolver/walk.ts | 39 +++++++++++++++---- .../dotted-nonblockparam-binding.gts | 15 +++++++ test/resolver/walk.test.ts | 22 +++++++++++ 3 files changed, 69 insertions(+), 7 deletions(-) create mode 100644 test/glint-fixtures/dotted-nonblockparam-binding.gts diff --git a/lib/resolver/walk.ts b/lib/resolver/walk.ts index 82fadb0..eef3bc1 100644 --- a/lib/resolver/walk.ts +++ b/lib/resolver/walk.ts @@ -911,12 +911,17 @@ export function resolveYieldHashBinding(opts: YieldHashBindingOptions): Resoluti // into its yield-hash. Mirrors HDS's `HdsFormCheckboxGroup` re-yielding // `HdsFormFieldset`'s `F.Legend` — without this `` fell back to // the binder's `
` Element type and FP-fired `wcag/h71`. +// Returns `null` (rather than TRANSPARENT) when `paramName` is not a +// block param introduced in this template — i.e. the `VarHead`+tail was +// general property access, not a re-yield. The caller then falls back to +// the prior `resolveByName(head)` behavior. A binder that IS found but +// fails to resolve still returns TRANSPARENT (the FP-safe answer). function resolveBlockParamReyield( paramName: string, hashKey: string, parentSource: TemplateSource, options: ResolveOptions, -): Resolution { +): Resolution | null { const depth = options.depth ?? 0; if (depth >= MAX_DEPTH) return TRANSPARENT; let ast: AST.Template; @@ -926,7 +931,7 @@ function resolveBlockParamReyield( return TRANSPARENT; } const binderNode = findBlockParamBinder(ast, paramName); - if (!binderNode) return TRANSPARENT; + if (!binderNode) return null; const binderTag = binderNode.tag; // The binder is invoked WITHIN this template (``), @@ -950,6 +955,12 @@ function resolveBlockParamReyield( } } + // Resolve the binder's source, mirroring `resolvePascalRecursion`'s + // lookup order so re-yield chains resolve under every consumer style: + // 1. `import Binder from '...'` in this template's origin. + // 2. Same-file declaration (`const Binder =