Skip to content

dogfood: html, css and TemplateResult resolve to any in every scaffolded app #1451

Description

@vivek7405

Problem

In a freshly scaffolded app, html is typed any. So is css, TemplateResult, Suspense, repeat, connectWS, richFetch and escapeText / escapeAttr. Every component's render() therefore has an inferred return type of any, and so does every page and layout that returns a template.

Reproduced in a real npm create webjs@latest app (webjs-test/demo-app, cli 0.10.56 / core 0.7.51, TypeScript 5.9.3), asking the language service for the type at modules/components/components/theme-context.ts:29:

(method) ThemeProvider.render(): any

This is what surfaced it: LazyVim (vtsls, bundled TypeScript 5.9.3) reports 'render' implicitly has return type 'any' because it does not have a return type annotation on that component, and underlines the provider field beside it. The editor is right, and the underlying degradation is real regardless of which editor shows it.

The cause is in packages/core/index.d.ts, which re-exports seven modules from their JSDoc .js implementation while the other twenty-five entries resolve to a hand-written .d.ts sibling:

  • index.d.ts:69 + :73 html, isTemplate, MARKER, type TemplateResult, from ./src/html.js
  • index.d.ts:74 css, isCSS, adoptStyles, stylesToString, from ./src/css.js
  • index.d.ts:78 escapeText, escapeAttr, from ./src/escape.js
  • index.d.ts:80 repeat, isRepeat, from ./src/repeat.js
  • index.d.ts:81 Suspense, isSuspense, from ./src/suspense.js
  • index.d.ts:82 connectWS, from ./src/websocket-client.js
  • index.d.ts:83 richFetch, from ./src/rich-fetch.js

There is no src/html.d.ts, css.d.ts, escape.d.ts, repeat.d.ts, suspense.d.ts, websocket-client.d.ts or rich-fetch.d.ts. A consumer with allowJs off, which is every scaffolded app, cannot read the JSDoc in those .js files, so each import resolves to any (TS7016), silenced by the scaffold's skipLibCheck: true.

It propagates past the direct imports, because two more overlays reach for the same untyped modules: src/component.d.ts:12-13 imports CSSResult from ./css.js and TemplateResult from ./html.js, src/routes.d.ts:26 imports TemplateResult from ./html.js, and src/directives.d.ts:1 re-exports repeat from ./repeat.js. So a component's render() return, its static styles, a page's PageProps return type and repeat from @webjsdev/core/directives are all unchecked today. Probed in the generated app: annotating a page's return as TemplateResult and returning a number, and setting static styles = 12345, both type-check silently.

The same probe found a second, unrelated break in the server overlay. packages/server/index.d.ts:122 types RequestHandler.handle as Handle, and the comment at :37-38 says the name arrives via the export * from './src/testing.d.ts' at :28. It does not: export * re-exports a name, it does not create a local binding. So that is TS2304: Cannot find name 'Handle' and handle degrades to an error type. Two TS2846s sit alongside it, from the explicit .d.ts extension in the export * specifiers at index.d.ts:11 (core) and :28 (server).

Design / approach

Add the seven missing .d.ts siblings, so the public surface is fully typed for a consumer that has allowJs off. They are pure declarations, no runtime change, and packages/core/package.json files already ships src whole, so nothing changes about packaging.

Every one of the seven already carries complete JSDoc, so the declarations can be generated rather than hand-written and then committed as normal overlays alongside their twenty-five siblings:

cd packages/core && npx tsc --allowJs --declaration --emitDeclarationOnly \
  --outDir /tmp/dtsgen --target ES2022 --module NodeNext \
  --moduleResolution NodeNext --skipLibCheck \
  src/html.js src/css.js src/escape.js src/repeat.js src/suspense.js \
  src/rich-fetch.js src/websocket-client.js

That was run during triage and produces correct output (html(strings: TemplateStringsArray | string[], ...values: unknown[]): TemplateResult, with TemplateResult exported as a type). Generation is a convenience for the first draft; the committed files are hand-maintained overlays from then on, exactly like the existing twenty-five, and both drift guards then cover them.

The fix was verified against the reporter's own app by dropping the seven generated files into its node_modules/@webjsdev/core/src/:

  • render() went from any to TemplateResult
  • html went from a bare import html to function html(strings: TemplateStringsArray | string[], ...values: unknown[]): TemplateResult
  • npx tsc --noEmit over the whole generated app stayed at zero errors, so the newly-real types do not red the gallery

Fix Handle in the same change with an explicit import type { Handle } from './src/testing.d.ts'; in packages/server/index.d.ts, and normalize the two export * specifiers off the explicit .d.ts extension.

Implementation notes (for the implementing agent)

Where to edit

  • Add packages/core/src/{html,css,escape,repeat,suspense,websocket-client,rich-fetch}.d.ts. Match the style of the existing overlays in that directory (signal.d.ts, task.d.ts, registry.d.ts are the closest models).
  • packages/server/index.d.ts: add the Handle import near :24 (which already imports types from @webjsdev/core), and delete the now-wrong comment at :37-38.
  • packages/core/index.d.ts:11 and packages/server/index.d.ts:28: the export * from './src/*.d.ts' specifiers.
  • test/types/dts-export-coverage.test.mjs: the guard hole, see below.

Landmines

Invariants to respect

  • packages/ is plain .js with JSDoc, never .ts (AGENTS.md, "Working in the WebJs framework repo itself"). .d.ts overlays are the established exception and what this issue adds.
  • Never change a Symbol('x') to Symbol.for('x') while moving declarations.
  • The prose-punctuation invariant (release: bump core/server/cli versions, honest engines fields #11) applies to the doc comments in the new files.

Tests + docs

  • Add a guard that would have caught this: a fixture typechecked the way an APP resolves the package, meaning allowJs OFF, asserting the public exports are not any. expectTypeOf-style assignability checks work without a new dependency: assign html to a mismatched type and require the error, or assert TemplateResult is not assignable to number. It must cover the whole public export list, not a sample, so a future untyped re-export fails.
  • Include the counterfactual: delete one of the seven new .d.ts and the new guard must fail naming that export.
  • RequestHandler.handle needs a type assertion too, or the Handle fix has no test.
  • Docs: no public API changes, so this is a types-only fix. references/typescript.md in .agents/skills/webjs/ is the surface to check if anything user-visible is worth stating; a bare internal type fix may correctly touch no doc surface (WEBJS_NO_DOC_GATE=1).

Acceptance criteria

  • packages/core/src/ has the seven missing .d.ts files, exporting the same names their .js does
  • In a freshly generated app, html resolves to (strings, ...values) => TemplateResult and a component's render() resolves to TemplateResult, not any
  • Annotating a page's return as TemplateResult and returning a number is a type error in a generated app; static styles = 12345 is a type error
  • repeat from @webjsdev/core/directives is typed, not any
  • packages/server/index.d.ts no longer references Handle without importing it, and RequestHandler.handle is a real function type
  • tsc --noEmit --skipLibCheck false over a generated app reports zero errors from @webjsdev/*
  • A new guard fails when any public export degrades to any, checked with allowJs OFF, with a counterfactual proving it fires
  • A freshly generated app of each template still passes webjs check and webjs typecheck with zero errors

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

Status
Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions