Skip to content

Support standard decorators in controller, register, target, targets and attr - #370

Draft
mattcosta7 wants to merge 5 commits into
mainfrom
mattcosta7-catalyst-dual-decorator-support
Draft

mattcosta7 wants to merge 5 commits into
mainfrom
mattcosta7-catalyst-dual-decorator-support

Conversation

@mattcosta7

@mattcosta7 mattcosta7 commented Nov 12, 2026 •

Copy link
Copy Markdown
Member

@controller, @register, @target, @targets and @attr now work with standard (TC39 2023-11) decorators as well as TypeScript's legacy experimentalDecorators. One import works in both modes, so a consumer can move off experimentalDecorators without a wrapper. Legacy calls take the existing code path. This follows github/memoize#52 and github/mini-throttle#45, and moves the logic of the @github-ui/catalyst wrapper from github/github-ui#43919 into Catalyst itself.

Why

TypeScript 5.0+, Babel and SWC (decoratorVersion: "2023-11") all support standard decorators. Today these decorators only accept the legacy (prototype, name) arguments. Under standard decorators TypeScript rejects them (TS1238, TS1240, TS1241), and at runtime they would get the wrong arguments.

How

Each decorator checks its second argument. An object with kind and addInitializer is a standard call. A string is a legacy call, which runs the existing code unchanged.

Decorator Standard mode
@controller, @controller('name') Registers the element from a class initializer, which runs after static fields such as observedAttributes and attrPrefix are assigned. That is when legacy class decorators run. A standard class decorator itself runs before them.
@attr Decorates a field, accessor, getter or setter. Records the name in decorator metadata. @controller, defineObservedAttributes and initializeAttrs register it. Registration walks undecorated base classes, reads each class's own metadata, and registers base class attrs first.
@register Defines the element from a class initializer, like @controller. Before this change it received the context object as the element name and threw.
@target, @targets Decorate a public instance accessor (@target accessor output!: HTMLElement). Each returns a getter for findTarget(s) and a setter that throws TypeError, matching the getter-only legacy property. Standard decorators can't decorate declare fields, and a plain field would shadow the getter.

Other members (static, private, symbol-named, or the wrong kind) throw a TypeError naming the supported kinds. TypeScript also rejects them at compile time, because it passes literal static/private types.

Symbol.metadata: the key is looked up for each class, with a Symbol.for('Symbol.metadata') fallback like the SWC and Babel helpers. It isn't cached at module load, because another library may define Symbol.metadata later. TypeScript only emits decorator metadata when Symbol.metadata exists at class definition, and no browser defines it natively yet. So when it's missing, Catalyst defines it as Symbol.for('Symbol.metadata') on import, as Lit does. This is the one exception to the "no polyfills" note in the guide, and the docs say so. The assignment is wrapped in try, so importing still works when Symbol is frozen (SES lockdown()).

Types: the standard overloads use small structural context types in src/decorator-context.ts rather than ClassDecoratorContext and friends. That way the published .d.ts still loads on TypeScript before 5.0. Legacy overloads come last and keep their signatures. One public type changes: controller(name) returns ControllerDecorator, an overloaded call signature that adds the standard shape. It's still assignable to (classObject: CustomElementClass) => void.

Compatibility

I don't believe this is a breaking change, based on the checks below. I packed the published 1.8.1 and this branch with npm pack and type-checked a consumer against each with skipLibCheck: false. The consumer used:

  • @controller with and without a name
  • @target/@targets on declare and ! fields (accessors in standard mode)
  • @attr on string, number and boolean fields, and on a getter/setter pair
  • direct controller(...), attr(...) and target(...) calls
  • assignment of controller(name) to the legacy decorator type
  • lib/abilities.js
  • a @ts-expect-error for an attr of type Date

The standard consumer also checks that plain-field and static @target uses are rejected.

TypeScript Mode 1.8.1 This branch
4.5.5 experimentalDecorators pass pass
4.9.5 experimentalDecorators pass pass
5.0.4 experimentalDecorators pass pass
5.2.2 experimentalDecorators pass pass
5.9.3 experimentalDecorators pass pass
5.0.4 standard fails (TS1238, TS1240, TS1241) pass
5.2.2 standard fails (TS1238, TS1240, TS1241) pass
5.9.3 standard fails (TS1240, TS1241) pass

@attr in standard mode needs decorator metadata, which TypeScript emits from 5.2. On 5.0 and 5.1 it type-checks but throws @attr requires decorator metadata when the class is defined. @controller and @target/@targets work from 5.0.

Legacy runtime: all 131 existing tests pass unchanged. The legacy path now also calls registerStandardAttrs, which finds no metadata on legacy classes. It creates the same meta entry CatalystDelegate already creates, so behaviour is unchanged.

Tests and tooling

npm test runs web-test-runner twice, so the same behaviour tests cover both modes:

  • Legacy run (web-test-runner.config.js): all of test/*.ts, compiled by esbuild with legacy decorators, as before. 139 tests.

  • Standard run (web-test-runner-standard.config.js): the shared decorator tests test/attr.ts, test/bind.ts, test/controller.ts and test/register.ts, compiled with standard decorators, plus test/standard/*.ts. 79 tests. A plugin compiles them with ts.transpileModule and experimentalDecorators: false before esbuild, because esbuild 0.14 only lowers legacy decorators. It emits ES2020 because esbuild's class lowering renames classes, and Catalyst derives tag names from class names.

  • Targets: the target assertions live in test/helpers/target-tests.ts. test/target.ts (@target foo!) and test/standard/target.ts (@target accessor foo!) each define the fixture and call the helper.

  • Shared cases, now run in both modes:

    • statics assigned before define
    • controller(name)(Class) registering attrs
    • attr inheritance from undecorated bases (3 levels, base first), from parent controllers, and between sibling subclasses
    • manual defineObservedAttributes/initializeAttrs
    • assigning to a target throws
  • Standard only (test/standard):

    • @attr accessor
    • a late Symbol.metadata
    • missing metadata
    • the polyfill (metadata.ts loads Catalyst before Lit)
    • wrong-kind errors and named assignment errors
  • Legacy only: the abilities/v2 decorator tests (ability, controllable, mark, providable), which this PR doesn't change.

  • Type checks: test/types/decorators.ts and the shared test files type-check in both modes. npm run lint runs tsc and tsc -p test/standard.

  • Frozen Symbol: test/frozen-symbol.ts imports Catalyst with Symbol frozen.

  • Mutation checks: each regression test fails without its fix:

    • registering without addInitializer
    • caching the metadata key
    • no base-class walk
    • appending to inherited metadata
    • no polyfill
    • no try around it
    • no registration in defineObservedAttributes/initializeAttrs

    Breaking only the standard @attr path fails the standard run, including shared test/controller.ts and test/attr.ts, while the legacy run still passes.

Tooling changes needed for the above:

  • typescript ^4.8.2 → ^5.9.3. Standard decorator metadata needs 5.2.
  • @typescript-eslint/* ^5.36.1 → ^5.62.0, for TS 5 syntax support. Prettier 2.6.2 → 2.8.8 in the lockfile only, within eslint-plugin-github's ^2.2.1 range, because 2.6 can't parse accessor.
  • tsconfig.json sets "types": ["mocha"]. TS 5.9 rejects the transitive @types/node@17 that was auto-included before.
  • src/bind.ts: removed a dead || 'handleEvent' after an object literal (always truthy), which TS 5.9 flags as TS2872. No behaviour change.
  • size-limit for {controller, attr, target, targets}: 2.5 kB → 3 kB. The bundle is 2.80 kB, up from 2.31 kB built the same way from main.

Docs

The v1 guides (decorators, targets, attrs and "you will need") now cover:

  • standard decorators
  • the accessor requirement for @target/@targets
  • TypeScript 5.2+ for @attr metadata
  • the Symbol.metadata definition

Runtime cost

Measured in Chromium with 2,000 elements, each with 3 targets and 2 attrs. Values are medians over 3 rotated rounds, imported from the built lib/:

Connect Target/attr reads Attr writes Define 200 classes
legacy, main 8.0–8.2 ms 3.0 ms 1.4–1.5 ms 1.2–1.3 ms
legacy, this branch 8.0–8.2 ms 2.9–3.0 ms 1.4–1.5 ms 1.4 ms
standard, this branch 8.9–9.5 ms 3.1–3.5 ms 1.4 ms 1.7–1.8 ms
  • Legacy: no measurable change.
  • Standard: about 10% slower to connect. I attribute that to the compiler's accessor output, which adds a private backing field per target on each instance, because Catalyst's connect path is the same code in both modes.
  • Unchanged files: their shipped JS is byte-identical to main built with TS 4.8.

Build note: Catalyst derives tag names from class names. If TypeScript emits standard decorators as ES2022 and esbuild then lowers that to an older target, the class is renamed (_a), and @controller throws when it defines the element. esbuild's own decorator lowering keeps names, and keepNames fixes the TS-then-esbuild case. The decorators guide now says this.

Not supported in standard mode

  • @target/@targets on plain fields, getters or setters. They need an accessor.
  • Static, private or symbol-named members for any member decorator. This matches what the legacy runtime effectively supports.
  • Standard @target doesn't record the name in meta(proto, 'target'). Nothing in 1.x reads that set.

npm run lint, npm run build, npm test (139 legacy + 79 standard, Chromium) and npm run size pass locally.

mattcosta7 and others added 5 commits November 12, 2026 17:02
The same exports now accept TypeScript's legacy experimentalDecorators and
standard (TC39 2023-11) decorator calls. Legacy calls take the existing code
path.

- @controller registers from a class initializer, after static fields.
- @attr records names in decorator metadata; @controller registers them,
  walking undecorated base classes. Symbol.metadata is defined if missing.
- @target/@targets decorate public instance accessors and throw on assignment.
- Context types are structural so the .d.ts loads on TypeScript < 5.0.
- Tests: test/standard runs in the browser compiled with standard decorators;
  test/types fixtures type-check in both modes via npm run lint.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…in manual setup

Importing Catalyst no longer throws when Symbol is frozen (SES lockdown).
defineObservedAttributes and initializeAttrs now pick up standard @attr
members without @controller, as they do for legacy decorators.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
npm test now runs web-test-runner twice. The second run compiles
test/attr.ts, test/bind.ts and test/controller.ts with standard decorators,
plus test/standard for standard-only syntax. Target assertions live in
test/helpers/target-tests.ts, used by a legacy and a standard fixture.
Cross-mode cases moved from test/standard into the shared files.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@register threw under standard decorators because it received the context
object as the element name. It now defines the element from a class
initializer, like @controller. test/register.ts runs in both modes.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@mattcosta7 mattcosta7 changed the title Support standard decorators in controller, target, targets and attr Support standard decorators in controller, register, target, targets and attr Nov 13, 2026

This branch has not been deployed

No deployments
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