ReviewOS

stacks/stx

stx.d.ts: toast's title/id/dark options and string dismiss are undeclared — #1913's own example fails stx typecheck

#1932
Closed glennmichael123 opened this 10 days ago ·
10 days ago

title, id and dark all shipped in the toast runtime for #1913, but stx.d.ts was never updated to match. The result is that the feature built to unblock a migration is unreachable from any typechecked call site — stx typecheck rejects the exact snippet the issue was closed with.

Found on 0.2.176 while doing the migration #1913 was filed for.

The drift

addToast reads four options (src/signals.ts):

var duration = opts.duration !== undefined ? opts.duration : 3000;  // 5808
var key      = opts.id != null ? String(opts.id) : '';              // 5821
var isDark   = _resolveDark(opts.dark);                             // 5843
if (opts.title) { ... }                                             // 5872

stx.d.ts declares one of the four:

interface StxToastOptions {
  /** Auto-dismiss duration in ms. 0 = persistent. Default: 3000 */
  duration?: number
}

interface StxToast {
  success: (message: string, options?: StxToastOptions) => number
  // ...
  /** Dismiss a toast by id, or all toasts if no id is given */
  dismiss: (id?: number) => void
}

dismiss is the second half: the runtime branches on typeof id === 'string' to look up [data-stx-toast-key], which is the whole point of replace-by-id, but the declaration accepts number only.

Reproduction

<script client>
  toast.success('Post published.', { title: 'Published', id: 'publish' })
  toast.dismiss('publish')
  toast.error('Nope.', { duration: 12000 })
</script>
4:40  error TS2353  Object literal may only specify known properties,
                    and 'title' does not exist in type 'StxToastOptions'.
5:19  error TS2345  Argument of type 'string' is not assignable to
                    parameter of type 'number'.

Both calls work correctly at runtime. duration — the one declared option — passes.

Suggested

interface StxToastOptions {
  /** Auto-dismiss duration in ms. 0 = persistent. Default: 3000 */
  duration?: number
  /** Rendered above the message in bold. Omit for the original single-line shape. */
  title?: string
  /** Semantic key. A second toast with the same key replaces the first in place. */
  id?: string
  /** Overrides the resolved colour scheme for this toast. */
  dark?: boolean
}

interface StxToast {
  // ...
  /** Dismiss by semantic key or by the handle `addToast` returned; all toasts if omitted. */
  dismiss: (id?: number | string) => void
}

Workaround, and why it only half-works

StxToastOptions is a bare global interface, so an app can merge into it from its own --lib file:

interface StxToastOptions { title?: string, id?: string, dark?: boolean }

That clears the first error. dismiss cannot be fixed the same way — it is declared as a property with a function type rather than a method, so a merging declaration does not add an overload, and the original signature wins. Every dismiss('key') call site needs a cast:

(toast.dismiss as (id?: number | string) => void)('publish')

Declaring dismiss as a method (dismiss(id?: number | string): void) would at least make it overload-mergeable by downstream apps, independent of this fix.

Why this keeps happening

This is the sixth stx.d.ts-vs-implementation drift I have hit in one app; the others were useEventListener arity, navigate's boolean shorthand, useRoute.fullPath, and useQuery's isStale / invalidate (the last two fixed in #1929). The pattern is consistent: a runtime option is added, the hand-maintained declaration is not, and nothing fails until someone runs stx typecheck.

Since stx typecheck is itself shipped, a test that typechecks a snippet using every documented option of each composable would catch the whole class. The #1913 closing comment is a ready-made case — it is currently a two-error snippet.

10 days ago

Fixed in c586c9b8ad. All four options declared, and dismiss takes string | number | undefined.

Your diagnosis was exact, so the interesting part is what I did beyond it. This is the third instance of one failure — useEventListener declared target-first while the runtime was event-first (#1923), StxQueryResult omitting isStale/invalidate (#1929), and now this. Each previous fix was to restate the runtime by hand in stx.d.ts, which is the thing that put it behind in the first place.

So there is now a guard that reads both sides. runtimeToastOptionReads extracts the option names out of addToast in the generated runtime, and test/signals/toast-declaration-drift.test.ts asserts the declaration covers them. Adding opts.position to the runtime fails until the type catches up.

It checks the other direction too, which is the quieter failure mode: an option declared but never read is a documented feature that silently does nothing.

The extractor throws rather than returning an empty set when it can't find addToast, following the discipline already in that module — a drift guard that silently checks nothing is worse than no guard, since it reports success for a surface it never looked at. Verified by sabotage in all three directions: dropping a declaration fails, adding an unread one fails, and renaming the runtime function fails loudly rather than passing vacuously.

Two small notes:

  • id is typed string | number, since the runtime does String(opts.id) and both are meaningful.
  • Your repro now type-checks as written, and { titel: 'Heading' } is still rejected — with a "did you mean title?" suggestion, which it can only give because title is declared. That assertion is in the test on purpose: options?: any would have made every other test pass while restoring exactly the blindness #1917 was about.

docs/api/builtins.md gained the options table and the replace-by-id example, since the feature had shipped undocumented as well as undeclared.

Sign in to comment on this issue.