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
9 changes: 9 additions & 0 deletions .changeset/hoisted-tag-var-never.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@marko/language-server": patch
"@marko/language-tools": patch
"@marko/ts-plugin": patch
"@marko/type-check": patch
"marko-vscode": patch
---

Fix tag variables being typed `never` when referenced before their declaration or hoisted out of control flow, since `Marko._.hoist` only preserved function-typed values. Most visibly, `<style/styles>` used above the style tag errored with "Property does not exist on type 'never'".
6 changes: 6 additions & 0 deletions agent-feedback/cleanup.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
# Cleanup

Duplication, dead code, inconsistencies, refactor opportunities. Format and rules: [README.md](README.md).

## Plumb the source node (or tag kind) through `getProgramBindings`

`packages/language-tools/src/extractors/script/util/attach-scopes.ts` › `getProgramBindings` | 2026-07-30 | impact:low | effort:med

`ProgramBinding` exposes only `{ name, sources }` (plus mutation info), dropping the declaring node/tag. This makes it impossible for the script extractor's program-level hoist emission (`packages/language-tools/src/extractors/script/index.ts` › `#writeProgram`) to treat any tag kind specially — e.g. declaring a `<style/styles>` CSS-module var with its statically known selector type at program scope instead of routing it through `Marko._.hoist`. If per-tag-kind program-level typing is ever wanted, the binding needs to carry its `Node.Tag` (or at least the tag name). Re-verify: inspect the `ProgramBinding` interface in attach-scopes.ts and confirm it has no node reference.
Comment on lines +4 to +9

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Record actionable feedback in agent-feedback/README.md. Both additions document out-of-scope follow-ups but omit the required README record.

  • agent-feedback/cleanup.md#L4-L9: add the ProgramBinding follow-up to agent-feedback/README.md.
  • agent-feedback/perf.md#L4-L9: add the tag-variable hoisting performance follow-up to agent-feedback/README.md.
📍 Affects 2 files
  • agent-feedback/cleanup.md#L4-L9 (this comment)
  • agent-feedback/perf.md#L4-L9
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@agent-feedback/cleanup.md` around lines 4 - 9, Record both documented
follow-ups in agent-feedback/README.md: add the ProgramBinding
source-node/tag-kind plumbing item from agent-feedback/cleanup.md lines 4-9, and
add the tag-variable hoisting performance item from agent-feedback/perf.md lines
4-9. Preserve the existing feedback details and link each README entry to its
corresponding document.

Source: Coding guidelines

6 changes: 6 additions & 0 deletions agent-feedback/perf.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
# Performance

Runtime speed and bundle size opportunities. Format and rules: [README.md](README.md).

## Tag-var hoisting is unconditional — no reference analysis

`packages/language-tools/src/extractors/script/util/attach-scopes.ts` › `attachScopes` | 2026-07-30 | impact:low | effort:high

Every tag-var binding is pushed to `potentialHoists` unconditionally, and every nested tag var whose walk reaches program scope becomes a `HoistedBinding` — whether or not anything outside its section references it. Each such binding costs a program-level `Marko._.hoist(...)` const plus `readScope`/`readScopes` machinery in the extracted TS, which the TypeScript checker must then evaluate per template. A reference-aware pass (only hoist names actually referenced outside their declaring scope) would shrink extracted output and checker work on templates with many tag vars. Re-verify: extract any template with a tag var inside an `<if>` that is never referenced elsewhere and observe the emitted `hoist`/`readScope` code.
Original file line number Diff line number Diff line change
@@ -1 +1 @@
<main data-marko-node-id="0" id="dynamic" class="dynamic"></main><div data-marko-node-id="1" class="dynamic"></div>
<section data-marko-node-id="0" class="dynamic"></section><main data-marko-node-id="1" id="dynamic" class="dynamic"></main><div data-marko-node-id="2" class="dynamic"></div>
Original file line number Diff line number Diff line change
@@ -1,22 +1,31 @@
## Hovers
### Ln 13, Col 35
### Ln 1, Col 23
```marko
11 | </style>
12 |
> 13 | <main id=styles.main class=styles.button/>
> 1 | <section class=styles.button/>
| ^ (property) "button": string
2 | // ^?
3 | <style/styles>
4 | .button {
```

### Ln 15, Col 35
```marko
13 | </style>
14 |
> 15 | <main id=styles.main class=styles.button/>
| ^ (property) "button": string
14 | // ^?
15 | <div class=styles.missing/>
16 |
16 | // ^?
17 | <div class=styles.missing/>
18 |
```

## Diagnostics
### Ln 15, Col 19
### Ln 17, Col 19
```marko
13 | <main id=styles.main class=styles.button/>
14 | // ^?
> 15 | <div class=styles.missing/>
15 | <main id=styles.main class=styles.button/>
16 | // ^?
> 17 | <div class=styles.missing/>
| ^^^^^^^ Property 'missing' does not exist on type '{ button: string; main: string; }'.
16 |
18 |
```

Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,21 @@ export interface Input {}
(Marko._.error, Marko._.any as MarkoRun.Context),
);
const styles = Marko._.hoist(() => __marko_internal_hoist__styles);
Marko._.renderNativeTag("section")()()({
class: styles.button,
});
{
const styles = Marko._.any as { button: string; main: string };
Marko._.renderNativeTag("style")()()({
[Marko._.content]: (() => {
return () => {
return Marko._.voidReturn;
};
})(),
});
Marko._.renderNativeTag("style")()()(
// ^?
{
[Marko._.content]: (() => {
return () => {
return Marko._.voidReturn;
};
})(),
},
);
Marko._.renderNativeTag("main")()()({
id: styles.main,
class: styles.button,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
<section class=styles.button/>
// ^?
<style/styles>
.button {
color: blue;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
20 |
21 | -- ${() => {
> 22 | a;
| ^ const a: never
| ^ const a: readonly ["apples", "oranges"]
23 | //^?
24 | b;
25 | //^?
Expand All @@ -47,7 +47,7 @@
22 | a;
23 | //^?
> 24 | b;
| ^ const b: never
| ^ const b: readonly ["slice", "dice"]
25 | //^?
26 | c;
27 | //^?
Expand Down
2 changes: 1 addition & 1 deletion packages/language-tools/marko.internal.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ declare global {

export function hoist<T>(
value: () => T,
): T extends () => infer R ? T & Iterable<R> : never;
): T extends () => infer R ? T & Iterable<R> : Exclude<T, undefined>;
// TODO: hoist should really be the below implementation which accounts for hoisting from unknown
// sections causing the getter to return undefined. Right now the type says it always has a value.
// export function hoist<T, U = T>(
Expand Down