Skip to content

fix(compiler): resolve @Prop variable defaults to literal values in docs output#6728

Merged
gnbm merged 11 commits into
mainfrom
FW-7298
May 21, 2026
Merged

fix(compiler): resolve @Prop variable defaults to literal values in docs output#6728
gnbm merged 11 commits into
mainfrom
FW-7298

Conversation

@gnbm
Copy link
Copy Markdown
Contributor

@gnbm gnbm commented May 20, 2026

Closes #6727

What is the current behavior?

Stencil emits the source text of a @Prop initializer verbatim into the generated docs JSON (defaultValue field). When the initializer is a reference to a const variable or an object-property access, the docs end up showing the variable expression rather than the actual default value.

Example from ion-split-pane (Ionic Framework):

const QUERY: { [key: string]: string } = { lg: '(min-width: 992px)', ... };

@Component({ tag: 'ion-split-pane' })
export class SplitPane {
  @Prop() when: string | boolean = QUERY['lg'];
}

Today the generated docs JSON contains:

"default": "QUERY['lg']"

…which is what users see in https://ionicframework.com/docs/api/split-pane#prop-when.

GitHub Issue Number: #6727

What is the new behavior?

The @Prop decorator parser now follows the initializer expression to its underlying primitive literal when it is safe to do so. With this fix, the same component produces:

"default": "'(min-width: 992px)'"

Shapes that are now resolved:

  • IDENT where IDENT is a const variable whose initializer chain ends in a primitive literal.
  • OBJ.key and OBJ['key'] (or OBJ[0]) against const object literals.
  • Object shorthand properties (const label = 'x'; const CONFIG = { label }; @Prop() v = CONFIG.label;) — resolved via getShorthandAssignmentValueSymbol.
  • Cross-file imported consts (import { QUERY } from './queries';) — alias symbols are unwrapped via getAliasedSymbol before walking declarations.
  • Value-preserving wrapper expressions are unwrapped: as ('x' as const), <T>x type assertions, parenthesized expressions, satisfies, and non-null !.
  • Wrapped object-literal consts (const CONFIG = ({ label: 'x' } as const)) — the object side of OBJ.key is also unwrapped.
  • Chained const-to-const object aliases (const A = CONFIG; @Prop() v = A.label;) — bounded by MAX_RESOLVE_DEPTH.
  • Signed numeric literals (-1, +2) — TypeScript represents these as PrefixUnaryExpression, now recognised as primitives.
  • const X = undefined; @Prop() v = X; resolves to the literal undefined.

Shapes that intentionally still fall back to the original getText() output (preserving prior behavior):

  • Nested property access (OBJ.a.b).
  • Template literals with substitutions.
  • Computed / dynamic indexed access (OBJ[varKey]).
  • Function calls, arithmetic, ternaries, etc.
  • Enum members (SomeEnum.VALUE) — out of scope to avoid silently changing existing docs strings.
  • let / var bindings — never followed because they may be reassigned.

Safety properties:

  • Only primitive literals (string / number / signed number / true / false / null / undefined) are emitted. Object and array literals are never serialized into defaultValue.
  • Recursion is bounded (MAX_RESOLVE_DEPTH = 5) for both the literal walk and the object-literal walk.
  • Source quote style is preserved (everything still flows through getText() at the leaf).
  • Any expression that can't be resolved falls back to the previous getText() output via ??, so no existing case regresses.

Documentation

N/A — internal compiler change. The Gallery component's hardcoded columns / gap description defaults in ionic-team/ionic-docs can be dropped once Ionic picks up this Stencil release; that follow-up belongs in the docs repo.

Does this introduce a breaking change?

  • Yes
  • No

The change only widens the set of expressions that are resolved into a literal. Every expression shape the previous code already produced output for produces the same output now.

Testing

Fifteen new unit tests in src/compiler/transformers/test/parse-props.spec.ts covering:

  • const string / number variable resolution.
  • OBJ.key and OBJ['key'] resolution — the latter uses the verbatim ion-split-pane shape (FW-7298 reproduction).
  • Object shorthand property resolution ({ label }).
  • Cross-file imported const resolution — self-contained 2-file ts.Program inline in the spec.
  • as const, parenthesized + non-null wrapper unwrapping.
  • Wrapped object-literal const accessed via property access.
  • Chained const-to-const object alias resolution.
  • Signed numeric literal (-1).
  • const X = undefined; @Prop() v = X; chain.
  • Function-call initializer / dynamic-key indexed access — must fall back to raw text.
  • Direct @Prop() x = undefined; — preserved as raw text.
npm run test.jest -- src/compiler/transformers/test/parse-props.spec.ts
# Tests: 40 passed, 40 total

Full transformers suite: 325 passed, zero regressions.

Other information

  • The fix is localized to src/compiler/transformers/decorators-to-static/prop-decorator.ts and the corresponding test file; no other files are touched.

…ocs output

When a @prop default value is a reference to a const variable, an object
property access, or an indexed object access, follow the reference to its
underlying primitive literal so generated docs JSON shows the real value
instead of the source-text expression. Falls back to the original
getText() output for any expression that cannot be safely resolved to a
primitive literal, so existing behavior is preserved for every case the
previous code already handled.

Closes #6727
Copilot AI review requested due to automatic review settings May 20, 2026 11:09
@gnbm gnbm requested a review from a team as a code owner May 20, 2026 11:09
Copy link
Copy Markdown
Contributor

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

This PR improves Stencil’s generated docs metadata for @Prop defaults by resolving certain safe initializer expressions (const identifiers and object property/index access) down to their underlying primitive literal text, so docs show the actual default value instead of a variable expression.

Changes:

  • Updated @Prop default value extraction to attempt literal resolution before falling back to initializer.getText().
  • Added a bounded resolver that follows const initializers and object literal member lookups for simple OBJ.key / OBJ['key'] / OBJ[0] forms.
  • Added unit tests covering the newly supported resolution cases and the fallback behavior.

Reviewed changes

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

File Description
src/compiler/transformers/decorators-to-static/prop-decorator.ts Routes defaultValue through a new resolver that tries to reduce supported initializer shapes to primitive literal text.
src/compiler/transformers/test/parse-props.spec.ts Adds tests for resolving defaults from const identifiers and object member access, plus fallback cases.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/compiler/transformers/decorators-to-static/prop-decorator.ts Outdated
Comment thread src/compiler/transformers/decorators-to-static/prop-decorator.ts
Comment thread src/compiler/transformers/test/parse-props.spec.ts
@gnbm gnbm requested a review from johnjenkins May 20, 2026 11:13
@gnbm gnbm marked this pull request as draft May 20, 2026 11:15
gnbm added 3 commits May 20, 2026 12:29
Address review feedback on #6728:

- `getConstVariableInitializer` now unwraps alias symbols via
  `getAliasedSymbol`, so a `@Prop` initialized from an imported `const`
  is correctly resolved to its underlying literal (previously the alias
  symbol's declarations didn't include the `VariableDeclaration`).
- Move the `undefined` identifier check into `isPrimitiveLiteral` so the
  inline comment / branch documentation matches the actual accepted set.
- Extend the test `transpileModule` helper with an optional `extraFiles`
  map plus a `resolveModuleNames` short-circuit, enabling multi-file
  cross-module symbol resolution in unit tests.
- Add a multi-file unit test that exercises the imported-const path
  through to the emitted `static get properties()` block.

Also reformats the helper block to match the repository's Prettier
configuration (resolves the failing `prettier.dry-run` CI step).
…ndling

- Apply Prettier formatting (resolves the failing `prettier.dry-run` CI step).
- Fold the special-case `undefined` identifier branch into `isPrimitiveLiteral`,
  so the inline documentation accurately describes the accepted set.

No behavior change.
@gnbm gnbm marked this pull request as ready for review May 20, 2026 14:42
Copilot AI review requested due to automatic review settings May 20, 2026 14:42
Copy link
Copy Markdown
Contributor

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

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

Comment thread src/compiler/transformers/decorators-to-static/prop-decorator.ts Outdated
@johnjenkins
Copy link
Copy Markdown
Contributor

johnjenkins commented May 20, 2026

hey @gnbm thanks for raising!

Can you work through / resolve the points raised by copilot - than I'll do a proper review :)

Also nit - but can we not refer to internal tickets (FW-*) as Stencil is an open-source project and we have no way of seeing OutSystem's internal Jira / tickets for context 🙏

@gnbm gnbm requested a review from OS-jacobbell May 20, 2026 17:08
…literal

Address Copilot review feedback on #6728: `isPrimitiveLiteral` previously
matched every Identifier named `undefined` solely by node text, which
mis-handled the (legal) case of a user shadowing `undefined` with a
local `const`. Drop the special case and let `getConstVariableInitializer`
walk the chain like any other identifier. The global `undefined` (declared
in `lib.es5.d.ts` without an initializer) naturally falls through to the
`getText()` fallback, so `@Prop() x = undefined;` still emits the string
`"undefined"` — verified by a new regression test.

No behavior change for unshadowed code; correctness fix for the
shadowed case.
Copy link
Copy Markdown
Contributor

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

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

Comment thread src/compiler/transformers/decorators-to-static/prop-decorator.ts
Comment thread src/compiler/transformers/decorators-to-static/prop-decorator.ts Outdated
…d tighten recursion guard

Address remaining Copilot review feedback on #6728:

- Unwrap alias symbols (`ImportSpecifier` / `NamespaceImport`) before
  searching declarations, so `@Prop` initializers that reference an
  imported `const` correctly resolve to the imported binding's literal
  value (e.g. `import { QUERY } from './q'; @prop() when = QUERY['lg'];`).
- Match `ShorthandPropertyAssignment` in `findObjectLiteralMember` and
  use `getShorthandAssignmentValueSymbol` to reach the in-scope binding,
  so defaults like `CONFIG.label` where `CONFIG = { label }` (shorthand)
  resolve correctly instead of falling back to raw text.
- Tighten the recursion guard from `depth > MAX_RESOLVE_DEPTH` to
  `depth >= MAX_RESOLVE_DEPTH` so the documented "max depth 5" matches
  actual behavior (5 levels, not 6).
- Extract `resolveConstSymbolInitializer` so symbol-to-initializer
  resolution (with alias unwrap + const guard) is shared between the
  identifier and shorthand paths.

Adds two new tests:

- Cross-file imported const resolution (self-contained 2-file
  `ts.Program` inline in the spec — does not touch the shared
  `transpileModule` helper).
- Object shorthand property resolution.

All existing tests still pass. Full transformers suite: 319/319.
Copy link
Copy Markdown
Contributor

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

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

Comments suppressed due to low confidence (1)

src/compiler/transformers/decorators-to-static/prop-decorator.ts:533

  • findObjectLiteralMember returns the first matching property assignment it encounters. In JavaScript object literals, later properties with the same key override earlier ones, so this can resolve an incorrect default value in the presence of duplicate keys. Consider iterating through all members and keeping the last matching initializer (while still ignoring spreads/computed names as you do now).
  for (const member of obj.properties) {
    if (ts.isPropertyAssignment(member)) {
      const memberName = getPropertyNameText(member.name);
      if (memberName === name) {
        return member.initializer;
      }
      continue;
    }
    // Shorthand: `{ label }` — equivalent to `{ label: label }`. The shorthand
    // name carries the property symbol, not the symbol of the in-scope binding,
    // so use `getShorthandAssignmentValueSymbol` to reach the original binding
    // and then walk to its `const` initializer.
    if (ts.isShorthandPropertyAssignment(member) && member.name.text === name) {
      return resolveConstSymbolInitializer(typeChecker.getShorthandAssignmentValueSymbol(member), typeChecker);
    }

Comment thread src/compiler/transformers/decorators-to-static/prop-decorator.ts
Comment thread src/compiler/transformers/test/parse-props.spec.ts
Address remaining Copilot review feedback on #6728:

- `resolveLiteralText` now unwraps value-preserving wrapper expressions
  before pattern-matching: `as` (`'x' as const`), `<T>x` type assertions,
  parenthesized expressions, `satisfies`, and non-null `!`. Common
  patterns like `const DEFAULT = 'x' as const; @prop() v = DEFAULT;`
  now resolve to the literal instead of falling back to the source.
- When an identifier chain terminates at a bare `undefined` global
  (e.g. `const DEFAULT = undefined; @prop() v = DEFAULT;`), emit the
  literal `'undefined'` so docs show the same value the runtime would
  observe. User-shadowed `const undefined = 'foo'` still resolves
  through the regular const path.

Adds three new tests covering `as const`, parenthesized + non-null
wrappers, and `const X = undefined; @prop() v = X;`. All existing
tests still pass. Full transformers suite: 322/322.
Copy link
Copy Markdown
Contributor

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

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

Comment thread src/compiler/transformers/decorators-to-static/prop-decorator.ts
Copy link
Copy Markdown
Contributor

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

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

Comment thread src/compiler/transformers/decorators-to-static/prop-decorator.ts Outdated
Comment thread src/compiler/transformers/decorators-to-static/prop-decorator.ts
Comment thread src/compiler/transformers/test/parse-props.spec.ts
…const aliases

Address remaining Copilot review feedback on #6728:

- Treat signed numeric literals (`-1`, `+2`) as primitive literals.
  TypeScript represents these as `PrefixUnaryExpression` wrapping a
  `NumericLiteral`, so `isPrimitiveLiteral` needs to recognise the
  shape via the new `isSignedNumericLiteral` helper.
- Extend `resolveObjectLiteral` to unwrap value-preserving wrappers
  (`as const`, `(...)`, `<T>`, `satisfies`, `!`) and to follow chained
  `const`-to-`const` identifier references when locating the underlying
  object literal. Bounded by `MAX_RESOLVE_DEPTH` so cyclic chains
  cannot blow the stack.
- Extract `unwrapValuePreservingWrappers` so both `resolveLiteralText`
  and `resolveObjectLiteral` use the same iterative helper.

Adds three new tests covering: negative numeric const,
`const CONFIG = ({...} as const)` property access, and
`const A = CONFIG; @prop() v = A.label;` chain. All existing tests
still pass. Full transformers suite: 325/325.
Copy link
Copy Markdown
Contributor

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

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

Copy link
Copy Markdown
Contributor

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

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (1)

src/compiler/transformers/decorators-to-static/prop-decorator.ts:549

  • Same off-by-one depth guard as resolveLiteralText: resolveObjectLiteral bails out when depth >= MAX_RESOLVE_DEPTH, but callers pass depth + 1 before following an identifier. This means an alias chain can hit depth === MAX_RESOLVE_DEPTH on the final object literal and still fail to resolve. Adjust the depth check so up-to-MAX_RESOLVE_DEPTH traversals actually succeed.
): ts.ObjectLiteralExpression | undefined => {
  if (depth >= MAX_RESOLVE_DEPTH) {
    return undefined;
  }

Comment thread src/compiler/transformers/decorators-to-static/prop-decorator.ts Outdated
Copy link
Copy Markdown
Contributor

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

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

Comment thread src/compiler/transformers/decorators-to-static/prop-decorator.ts
Comment thread src/compiler/transformers/decorators-to-static/prop-decorator.ts Outdated
… docs

Address remaining Copilot review feedback on #6728:

- Element-access key expressions are now unwrapped before the
  string/numeric literal check, so safe shapes like `OBJ[('lg')]` or
  `OBJ['lg' as const]` resolve instead of falling back to raw text.
- Depth guard reverted to `depth > MAX_RESOLVE_DEPTH` so a 5-hop
  const chain still reaches its primitive leaf (the previous `>=`
  was off-by-one in the other direction). The constant doc comment
  is rewritten to spell out the exact semantics — "up to N hops"
  — so the guard, constant, and comment all agree.
- Doc comment for `resolveConstSymbolInitializer` corrected:
  it returns the initializer expression as-is (not only when it
  is "literal-bearing") and leaves the literal-ness decision to
  the caller.

Adds one test for the wrapped element-access key. All existing tests
still pass. Full transformers suite: 326/326.
@gnbm
Copy link
Copy Markdown
Contributor Author

gnbm commented May 20, 2026

hey @gnbm thanks for raising!

Can you work through / resolve the points raised by copilot - than I'll do a proper review :)

Also nit - but can we not refer to internal tickets (FW-*) as Stencil is an open-source project and we have no way of seeing OutSystem's internal Jira / tickets for context 🙏

Hey @johnjenkins
I think I addressed them all. Thank you for the heads-up

Copy link
Copy Markdown
Contributor

@johnjenkins johnjenkins left a comment

Choose a reason for hiding this comment

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

just some tiny things. Don't worry about them if you don't have time :)

Comment thread src/compiler/transformers/decorators-to-static/prop-decorator.ts
Comment thread src/compiler/transformers/decorators-to-static/prop-decorator.ts
Introduce getPropertyNameText in decorators-to-static/prop-decorator.ts to return static text for object-literal property names and deliberately return undefined for computed names (avoiding compile-time evaluation of arbitrary expressions). Add a unit test in parse-props.spec.ts that ensures the resolver falls back to the original source text when a value chain exceeds MAX_RESOLVE_DEPTH (verifying fallback to getText()).
Copy link
Copy Markdown
Contributor

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

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

@OS-jacobbell
Copy link
Copy Markdown
Contributor

Hi @gnbm, this works for ion-split-pane's when and ion-gallery's gap, but still leaves ion-gallery's columns as DEFAULT_COLUMNS. Here's the before and after generated docs:
ionic-docs-diff.tar.gz

When a prop's default is a const that references an object/array literal (including cases using `satisfies`), emit the literal's source text so docs show the actual default instead of the identifier. Add a guard in resolveLiteralText to return node.getText() for object and array literals. Include tests for object literal resolved via `satisfies` and for array literal consts (parse-props.spec.ts).
@gnbm gnbm added this pull request to the merge queue May 21, 2026
Merged via the queue into main with commit 6cdcec7 May 21, 2026
35 checks passed
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.

bug: generatedproperty documentation does not resolve variables set as the default value

4 participants