Skip to content

feat(nanoviews): let an effect attribute name the element it sits on - #206

Merged
dangreen merged 1 commit into
mainfrom
feat/nanoviews-targeted-effect-attributes
Aug 20, 2026
Merged

feat(nanoviews): let an effect attribute name the element it sits on#206
dangreen merged 1 commit into
mainfrom
feat/nanoviews-targeted-effect-attributes

Conversation

@dangreen

Copy link
Copy Markdown
Member

An effect attribute declares the type of the value it takes:

declare module 'nanoviews' {
  interface EffectAttributeValues {
    ref$: WritableSignal<Element | null>
  }
}

Written once, for every element. For ref$ that is not merely imprecise — it is wrong in a way that costs the consumer something. A signal is invariant, so WritableSignal<HTMLButtonElement | null> is not a WritableSignal<Element | null>, and naming the element you are referencing was rejected:

TS2418: Type of computed property's value is 'WritableSignal<HTMLButtonElement | null>',
        which is not assignable to type 'WritableSignal<Element | null>'.

So the only legal spelling was signal<Element | null>(null), and every read of it needed a cast to reach anything the element actually has.

The map takes the target

declare module 'nanoviews' {
  // The value an attribute takes may be about the element it sits on, so the
  // map is asked with the target and answers for it. An attribute that does
  // not care simply never mentions it
  interface EffectAttributeValues<Target extends Element> {
  }
}

PickEffectAttributesByTarget already knew the element — it uses it to decide whether an attribute applies. Now it passes it on to decide what the attribute takes. Attributes stay in one map and keep declaring themselves from their own file; the four that do not care about the element (classList$, style$, autoFocus$, the controls) only repeat the parameter list that augmenting a generic interface requires.

What ref$ says now

  interface EffectAttributeValues<Target extends Element> {
    // A signal is invariant, so every type a ref may be declared with has to
    // be named: the element itself, the two branches of the tree it belongs
    // to, and their root
    ref$: WritableSignal<Target | null>
      | WritableSignal<HTMLElement | null>
      | WritableSignal<SVGElement | null>
      | WritableSignal<Element | null>
  }

Naming the arms is not laziness — TypeScript has no way to say "any supertype of Target". Two shapes that would avoid the list were tried and neither works. A type-level lambda (<T extends Element>(target: T) => WritableSignal<T | null> applied through infer) does not substitute the argument: TypeScript erases the parameter to its constraint, and <T>(t: T) => T[] applied to number comes back unknown[]. A structural sink ((value: Target | null) => void, "anything the element can be written into") accepts everything, including a signal of a different element — a signal's getter overload takes no arguments, and a zero-argument function satisfies any one-argument target.

What this buys

const ref = signal<HTMLButtonElement | null>(null)

button({ [ref$]: ref })('Click me!')

ref()!.type // 'submit' — no cast

signal<HTMLElement | null> and signal<Element | null> still fit, so nothing that compiled before stops compiling. signal<HTMLInputElement | null> on a button does not, and the test asserting that is a real gate: it uses @ts-expect-error, so if the check ever stops working the suppression becomes unused and tsc fails.

Types only. The runtime is untouched and every bundle is byte-identical.

`EffectAttributeValues` takes the target element, so a value type can be about the element rather than fixed once for all of them. Attributes that do not care never mention it.

`ref$` is the one that does. Its signal used to be typed `WritableSignal<Element | null>`, which did not merely fail to infer the element - it forbade naming it: a signal is invariant, so `signal<HTMLButtonElement | null>(null)` on a button was rejected outright, and the only legal spelling was `Element | null` with a cast at every use. The accepted types are now named instead: the element itself, `HTMLElement`, `SVGElement` and `Element`, so both the precise signal and the loose one fit, and a signal of some other element does not.

Types only - the runtime is untouched and no bundle moves.
@codecov

codecov Bot commented Aug 20, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 85.32%. Comparing base (6e6568b) to head (92fc438).

Additional details and impacted files
@@           Coverage Diff           @@
##             main     #206   +/-   ##
=======================================
  Coverage   85.32%   85.32%           
=======================================
  Files         139      139           
  Lines        3142     3142           
  Branches      591      591           
=======================================
  Hits         2681     2681           
  Misses        332      332           
  Partials      129      129           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@dangreen
dangreen merged commit 215f157 into main Aug 20, 2026
10 checks passed
@dangreen
dangreen deleted the feat/nanoviews-targeted-effect-attributes branch August 20, 2026 15:28
@github-actions github-actions Bot mentioned this pull request Aug 20, 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.

1 participant