Skip to content

fix(markdown-template): preserve parent scope in conditional and optional blocks#647

Open
yashhzd wants to merge 1 commit intoaccordproject:mainfrom
yashhzd:yashhzd/i2/fix-variables-in-conditional-optional-blocks
Open

fix(markdown-template): preserve parent scope in conditional and optional blocks#647
yashhzd wants to merge 1 commit intoaccordproject:mainfrom
yashhzd:yashhzd/i2/fix-variables-in-conditional-optional-blocks

Conversation

@yashhzd
Copy link

@yashhzd yashhzd commented Feb 26, 2026

Description

Fixes a bug where variables inside {{#if}} conditional blocks and {{#optional}} blocks with primitive types would fail with "Unknown property" errors during type-checking.

Root cause: ConditionalDefinition and OptionalDefinition (for primitive types) incorrectly changed the model scope to the condition/optional property itself, rather than preserving the parent class scope. This meant child variable references like {{age}} inside {{#if age}}...{{/if}} could not resolve against the parent model.

Closes accordproject/template-playground#2

Changes

TypeVisitor.js

  1. ConditionalDefinition — Pass currentModel (parent scope) instead of property to whenTrue children. Conditional blocks are guards — they should not change variable resolution scope.

  2. OptionalDefinition — For primitive optional properties, keep the parent model scope (currentModel) instead of narrowing to the property. Complex (object) optional types still correctly change scope to the nested class declaration.

  3. VariableDefinition {{this}} handler — Add type guard (typeof property.isPrimitive === 'function') to prevent crash when currentModel is a ClassDeclaration (which doesn't have isPrimitive()).

Tests

Added 6 new test cases:

  • 3 for conditional blocks (#if): named variables, multiple variables, unknown variable rejection
  • 3 for optional blocks (#optional): primitive named variables, different variable types, unknown variable rejection

All 13 tests pass (7 existing + 6 new).

Related

This fix works in conjunction with a companion PR on template-engine that addresses the runtime side of the same issue.

Author Checklist

  • Code compiles and passes tests
  • New tests added for changed behavior
  • Signed-off-by for DCO

…onal blocks

ConditionalDefinition (#if) and OptionalDefinition (#optional) blocks
with primitive types incorrectly changed the model scope to the condition
property, preventing child variables from resolving against the parent
class declaration. This caused 'Unknown property' errors when using named
variables like {{age}} inside {{#if age}}...{{/if}} blocks.

Changes:
- ConditionalDefinition: pass currentModel instead of property to
  whenTrue children so variables resolve against the parent scope
- OptionalDefinition: for primitive types, keep parent model scope
  instead of narrowing to the property
- VariableDefinition: add type guard for {{this}} handler to prevent
  crash when currentModel is a ClassDeclaration

Closes accordproject/template-playground#2

Signed-off-by: Yash Goel <162511050+yashhzd@users.noreply.github.com>
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes TemplateMark type-checking scope resolution so variables inside {{#if}} and primitive {{#optional}} blocks resolve against the parent model (avoiding “Unknown property” errors).

Changes:

  • Update TypeVisitor to preserve parent scope for ConditionalDefinition children and primitive OptionalDefinition blocks.
  • Add an additional type guard in the {{this}} handler to avoid crashes when the current model is a ClassDeclaration.
  • Add new tests covering variable resolution within #if and #optional blocks.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.

File Description
packages/markdown-template/src/TypeVisitor.js Adjusts scope handling for #if and #optional during typing; adds a guard in the {{this}} handler.
packages/markdown-template/lib/TypeVisitor.js Transpiled/built output reflecting the TypeVisitor changes.
packages/markdown-template/test/TemplateMarkTransformer.js Adds test cases for variable resolution in #if and #optional blocks.
Comments suppressed due to low confidence (1)

packages/markdown-template/lib/TypeVisitor.js:323

  • ConditionalDefinition keeps model: currentModel for whenTrue, but the {{else}} branch (whenFalse) is still visited with model: null just below. That will make any {{variable}} inside {{else}} fail type-checking, even though runtime conversion evaluates both branches with the same data scope. Consider passing currentModel for whenFalse as well (and regenerate lib/ from src/ after the fix).
          TypeVisitor.visitChildren(this, thing, {
            templateMarkModelManager: parameters.templateMarkModelManager,
            introspector: parameters.introspector,
            model: null,
            kind: parameters.kind

You can also share your feedback on Copilot code review. Take the survey.

Comment on lines 303 to 307
templateMarkModelManager:parameters.templateMarkModelManager,
introspector:parameters.introspector,
model:nextModel,
model:currentModel,
kind:parameters.kind
}, 'whenTrue');
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ConditionalDefinition now correctly keeps model: currentModel for the whenTrue branch, but the {{else}} branch (whenFalse) is still visited with model: null later in this case. That makes any {{variable}} inside {{else}} fail type-checking, even though runtime conversion (ToCiceroMarkVisitor) evaluates both branches with the same parameters.data. Consider passing currentModel for whenFalse as well so variable resolution is consistent across both branches.

Copilot uses AI. Check for mistakes.
Comment on lines 327 to +331
if (property.isPrimitive()) {
thing.elementType = property.getFullyQualifiedTypeName();
nextModel = property;
// For primitive optional properties, keep the parent model scope
// so that named variables (e.g. {{age}}) can resolve correctly.
// The property itself is passed as parentModel for {{this}} fallback.
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In OptionalDefinition, primitive optionals now keep nextModel = currentModel so named vars resolve against the parent. However, that also changes how {{this}} is typed inside the #optional block: VariableDefinition treats this as the current model, which is now a ClassDeclaration, so {{this}} will be annotated as the parent class type instead of the optional property type. If templates rely on {{this}} inside {{#optional <primitive>}}...{{/optional}} to mean the optional value, consider passing the optional property as a separate parameter for the {{this}} handler (and add a regression test). Also, the {{else}} branch (whenNone) is still visited with model: null later, so variables in {{else}} will still be rejected during type-checking.

Copilot uses AI. Check for mistakes.
Comment on lines +329 to +331
// For primitive optional properties, keep the parent model scope
// so that named variables (e.g. {{age}}) can resolve correctly.
// The property itself is passed as parentModel for {{this}} fallback.
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment "The property itself is passed as parentModel for {{this}} fallback" is currently misleading: visitChildren is only passed { model: nextModel, ... }, and no parentModel (or similar) parameter exists/gets forwarded. Either implement the separate context for {{this}} (as the comment implies) or adjust/remove the comment to match the actual behavior.

Suggested change
// For primitive optional properties, keep the parent model scope
// so that named variables (e.g. {{age}}) can resolve correctly.
// The property itself is passed as parentModel for {{this}} fallback.
// For primitive optional properties, keep the parent model as the
// current scope so that named variables (e.g. {{age}}) resolve
// against the same model; no separate parentModel context is used.

Copilot uses AI. Check for mistakes.
Comment on lines +341 to +344
// For primitive optional properties, keep the parent model scope
// so that named variables (e.g. {{age}}) can resolve correctly.
// The property itself is passed as parentModel for {{this}} fallback.
_nextModel2 = currentModel;
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For primitive OptionalDefinition, switching _nextModel2 = currentModel fixes named variable resolution but also changes {{this}} typing inside the optional block: the {{this}} handler uses the current model, which is now the parent class, so {{this}} gets annotated as the parent class type instead of the optional property type. Also, the {{else}} branch (whenNone) is still visited with model: null later, so variables in {{else}} will still be rejected during type-checking. If {{this}} is expected to mean the optional value, pass the property as a separate parameter for {{this}} and add a regression test; then regenerate lib/ from src/.

Suggested change
// For primitive optional properties, keep the parent model scope
// so that named variables (e.g. {{age}}) can resolve correctly.
// The property itself is passed as parentModel for {{this}} fallback.
_nextModel2 = currentModel;
// For primitive optional properties, do not change the scope model.
// This avoids annotating {{this}} as the parent class type; instead,
// type information for {{this}} should come from thing.elementType.
_nextModel2 = null;

Copilot uses AI. Check for mistakes.
@sanketshevkar
Copy link
Member

Hi @yashhzd, I see a very deep root cause analysis done by @Shubh-Raj and this two PRs you raised just implements those changes.

I'm closing this and the other PR for now. I'm not sure you'd had an offline conversation with him about this. it'll be better to ask him first if he's okay with this. I'd suggest please start a conversation on the issue and ask @Shubh-Raj for this. Let us give him a week to respond, if he doesn't if we can look into reviewing and merging the PRs.

@Shubh-Raj
Copy link
Contributor

Hi @yashhzd, I see a very deep root cause analysis done by @Shubh-Raj and this two PRs you raised just implements those changes.

I'm closing this and the other PR for now. I'm not sure you'd had an offline conversation with him about this. it'll be better to ask him first if he's okay with this. I'd suggest please start a conversation on the issue and ask @Shubh-Raj for this. Let us give him a week to respond, if he doesn't if we can look into reviewing and merging the PRs.

Thanks @sanketshevkar for handling this fairly! Happy to review @yashhzd's PRs and collaborate on getting the best fix merged.

@sanketshevkar sanketshevkar reopened this Mar 9, 2026
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.

Cannot use variables inside an #if block

4 participants