Skip to content

Webview API package preview hardening and consumer reshape#676

Merged
tnaum-ms merged 36 commits into
mainfrom
dev/tnaum/webview-api-package
May 26, 2026
Merged

Webview API package preview hardening and consumer reshape#676
tnaum-ms merged 36 commits into
mainfrom
dev/tnaum/webview-api-package

Conversation

@tnaum-ms
Copy link
Copy Markdown
Collaborator

@tnaum-ms tnaum-ms commented May 22, 2026

Summary

Preview hardening for @microsoft/vscode-ext-react-webview plus a reshape of the consumer-side integration layer in src/webviews/. Both halves land together so the package's preview surface and the in-tree consumer that exercises it stay in lock step.

Branch: dev/tnaum/webview-api-package (34 commits, all atomic).

What changed

Package (packages/vscode-ext-react-webview/)

  • New TypedEventSink<T> under /server: typed async-iterable for bridging push-style domain events into tRPC subscriptions (7-test suite).
  • New errorLink under the default entry plus an optional onError argument on useTrpcClient: opt-in webview-side error observer for queries and mutations (6-test suite). Subscriptions intentionally skipped so errors are not double-reported.
  • WebviewController now routes every postMessage call through a private safePostMessage helper that guards against disposed panels (both synchronous throws and late Thenable rejections).
  • README rewritten with a Quick start (four-file minimal example), a prominent pointer at the vscode-webview-starter-kit, and additional Advanced subsections (push events, error observer, type-only import rule). All em-dashes removed.

Package hardening (added during review)

  • TypedEventSink iterator now implements return(): marks the sink closed, drops buffered events, resolves a pending next() with { done: true }, and releases the single-consumer guard. break inside a for await over a sink now releases the parked promise per the JS iterator protocol with no producer cooperation.
  • WebviewController tracks both the AbortController and the live AsyncIterator per subscription. handleSubscriptionStopMessage and dispose() now abort and call iterator.return(), propagating cleanup through the procedure's async generator into any inner for await.
  • _onDisposed emitter registered in _disposables so it is torn down on panel dispose.
  • _activeSubscriptions.set deferred until after the iterator is in hand; early failures fall through to the outer catch without inserting a stale entry.
  • Dropped @vscode/l10n from peerDependencies, the README peer-dep table, and the package surface.
  • Build discipline: pre* hooks added so every bundle/watch script runs npm run build --workspaces --if-present first.

Consumer (src/webviews/_integration/, was src/webviews/api/)

  • Folder renamed to _integration/ so it sorts above feature folders in the explorer (the conventional "infrastructure, not feature code" signal).
  • trpc.ts merged into appRouter.ts (5 files -> 4).
  • BaseRouterContext now intersects the framework type (no more duplicated telemetry? / signal?).
  • Local WebviewController renamed to WebviewControllerBase to stop shadowing the framework class.
  • New configuration.ts holds the consumer-owned knobs (telemetry namespace, bundle layout, dev-server host). Three telemetry prefixes derived from one constant.
  • New folder README signposts files plus the per-view router convention.
  • Skill docs (webview-trpc-messaging, react-webview-architecture) updated to the new shape.

Final consumer shape:

src/webviews/_integration/
  README.md
  configuration.ts
  appRouter.ts
  useTrpcClient.ts
  WebviewControllerBase.ts
  WebviewRegistry.ts

Verification

  • npm run l10n no-op.
  • npm run prettier-fix no rewrites.
  • npm run lint clean (only the pre-existing webpack.config.views.js ESLint env warning).
  • npx jest --no-coverage 96 suites, 1952 tests, 4 snapshots green.
  • npm run build clean across all workspaces.

Why this matters (and what was decided against)

Full rationale, alternatives considered, and decisions taken (folder naming, file boundaries, package scope, what we did not adopt from the partner extension's tRPC migration) are tracked in docs/ai-and-plans/PRs/676-webview-api-package-preview-hardening.md in this PR.

Highlights:

  • Package stays a strict transport. Accessibility helpers, Monaco wiring, and UX policy belong in the starter kit or in consumer code, not in the npm package. The four package additions in this PR (TypedEventSink, errorLink, safePostMessage, README) are all pure transport plumbing or composable utilities; none import a UI library.
  • _integration/ chosen over webviewIntegration/. Sort order + brevity won; documented underscore-prefix convention.
  • Six-commit decomposition for the consumer reshape, each independently green. Reviewers can walk the history step by step.
  • Cosmos DB-style per-panel tRPC instances rejected as too much boilerplate for marginal type-safety gain. Their TypedEventSink pattern was adopted (it is the migration carrot when they move to this package); their centralised routers/schemas, generic command-dispatch, and mutable-state-in-ctx patterns were not.

Follow-up (out of scope for this PR)

  • Starter kit migration tracked at tnaum-ms/vscode-webview-starter-kit#2 through #7.
  • npm publish gated on starter kit catching up plus one external smoke test. Package is still 0.8.0-preview.
  • Optional consumer follow-up: promote commonRouter.displayErrorMessage and openUrl to publicProcedureWithTelemetry for baseline rpc traces. Skipped here to keep PR scope tight.

Commits

28f38a2d fix(lockfile): restore optional-peer chokidar entry for npm ci
d21d55b7 fix(packages): release async-iterator subscriptions on stop and dispose
5e67c142 build: build workspace packages before every bundle/watch script
00f36644 chore: regenerate package-lock.json after webview package rename
edd12741 docs(packages): fix JSDoc import path for server symbols
588a347c fix(packages): release the onDisposed emitter when the controller is disposed
b001773d docs(packages): clarify react-dom is not a peer of the webview package
351d1316 fix(packages): drop @vscode/l10n from the webview package surface
4d9cdaa4 fix(packages): don't register subscription before iterator is in hand
4d17269d docs(ai-and-plans): prefix PR doc filename with PR number per convention
669584c4 docs(ai-and-plans): add PR doc for webview API package preview hardening
6f2d6f1b docs(packages): expand README with quick start and starter kit pointers
cb4c5a8a fix(packages): guard postMessage against disposed panels in WebviewController
e9db4890 feat(packages): add errorLink and useTrpcClient onError option
57bed2b2 feat(packages): add TypedEventSink for push events through tRPC subscriptions
5232d60a docs(packages): document type-only AppRouter import rule for webview code
c8a6fb1f refactor(webviews)!: rename _integration folder for sort-order clarity
98d763b3 style(skills): prettier-fix table column alignment in webview-trpc-messaging
67af377d refactor(webviews)!: rename src/webviews/api/ to src/webviews/webviewIntegration/
4179ec51 feat(webviews): add api/configuration.ts for consumer-owned knobs
e6d3a277 refactor(webviews)!: rename DocumentDB WebviewController to WebviewControllerBase
f0cf4d00 refactor(webviews): extend framework BaseRouterContext instead of redeclaring
45f117f5 refactor(webviews): merge trpc.ts into appRouter.ts
2e3b5145 docs(webviews): add api/ folder signpost and per-view router convention
a80e1843 style(packages): prettier-fix README code samples

(Plus 9 pre-existing commits on the branch that built up the package extraction itself: scope tightening, README, helper re-homing, package rename. See git log 4888c489^..a80e1843.)

tnaum-ms added 24 commits May 22, 2026 08:53
Extracts the generic webview API infrastructure into a new local
workspace package alongside the existing @documentdb-js/* packages.

Adopts the simplifications and bug fixes from the
tnaum-ms/vscode-webview-starter-kit reference repository:

- Pluggable telemetry: generic TelemetryContext interface +
  createMiddleware factory; default console.log sink. The extension
  plugs in its own callWithTelemetryAndErrorHandling-backed middleware.
- WebviewController genericised over <TRouter, TConfiguration,
  TContext> and parameterised with appRouter + bundle layout via
  constructor options (no static appRouter import; no ext.isBundle
  coupling).
- Fix: void mutations now coalesce undefined to null so the client
  observable completes — the structured-clone algorithm strips
  undefined over postMessage.
- Drop redundant _onDisposed.dispose() after fire() in dispose().
- vscodeLink.ts and useTrpcClient genericised over consumer AppRouter.
- New TrpcClient<TRouter> type alias.
- Auto-recorded durationMs per RPC in the default middleware.
- Ported vscodeLink.test.ts (18 cases) as the link behavioural
  contract; wired into the root Jest projects.

Split entry points keep the webview bundle free of Node/vscode deps:
  - main entry (.)       — webview-client only
  - /server subpath      — extension-host APIs (WebviewController,
                            router, publicProcedure, createMiddleware,
                            BaseRouterContext, TelemetryContext,
                            WithTelemetry, ...)

The extension does not yet import from this package; the migration
follows in a subsequent commit.
Replaces the in-tree src/webviews/api/{extension-server,webview-client,
configuration}/ folders with thin app-specific wrappers that bind the
generic framework package to DocumentDB:

- src/webviews/api/appRouter.ts        — root tRPC tree +
  DocumentDB BaseRouterContext (extends the framework base with
  dbExperience + webviewName; telemetry typed against the framework's
  TelemetryContext)
- src/webviews/api/WebviewRegistry.ts  — app-specific webview keys
- src/webviews/api/trpc.ts             — DocumentDB telemetry
  middleware preserving the existing documentDB.rpc.* event-name
  convention; redefines WithTelemetry<T> to surface ITelemetryContext
  (suppressAll, etc.)
- src/webviews/api/WebviewController.ts — DocumentDB-tuned base class
  pre-filling the framework's WebviewControllerOptions with appRouter,
  bundle layout, and dev-server host
- src/webviews/api/useTrpcClient.ts    — AppRouter-bound hook wrapper

Webview components import accessibility/configuration helpers directly
from @microsoft/vscode-webview-api; extension-host code imports from
@microsoft/vscode-webview-api/server. The old WebviewContext.tsx and
the three subfolders under src/webviews/api/ are removed.
Updates the in-tree GitHub Copilot skill files to reflect the
relocated symbols after the @microsoft/vscode-webview-api extraction:

  src/webviews/api/extension-server/   ->  @microsoft/vscode-webview-api/server
  src/webviews/api/webview-client/     ->  @microsoft/vscode-webview-api
  src/webviews/api/configuration/      ->  src/webviews/api/{appRouter,WebviewRegistry}.ts

Also regenerates l10n/bundle.l10n.json after the API surface move.
…preview

Renames the workspace package to better differentiate it from VS Code
core APIs:

  @microsoft/vscode-webview-api -> @microsoft/vscode-ext-react-webview
  packages/vscode-webview-api/  -> packages/vscode-ext-react-webview/

- Bumps version to 0.8.0-preview, aligning with the parent
  vscode-documentdb extension that currently ships it.
- Updates the description to: "Webview infrastructure for VS Code
  extensions with type-safe tRPC RPC over postMessage, React hooks,
  pluggable telemetry middleware, and accessibility helpers".
- Adds a README marking the package as Preview, documenting the two
  entry points (main + /server), and listing peer dependencies.
- Updates every import path, project reference, Jest project entry,
  and skill doc reference to the new package name and folder.
Adds a "Scope" section to the README to set expectations for what does
and does not belong in this package:

- In scope: the webview transport (tRPC over postMessage,
  WebviewController, React hooks) plus minimum-viable WCAG helpers
  every VS Code webview eventually needs (currently: Announcer).
- Out of scope: general-purpose React UI / accessibility toolbox
  (focus traps, keyboard shortcut managers, skip-links, route
  announcers, etc.). Consumers should reach for react-aria or their
  in-house framework for those.

New helpers only land here when they are (a) tightly coupled to the
VS Code webview lifecycle and (b) the same boilerplate every consumer
would otherwise write themselves.
Relocates the Announcer screen-reader live-region component out of the
@microsoft/vscode-ext-react-webview package and back into the extension
where it is actually used. The package's job is the webview transport
(tRPC over postMessage) and the minimum React glue for it; accessibility
patterns belong in the consumer.

- Adds src/webviews/components/accessibility/Announcer.tsx (and index.ts
  barrel) — content copied verbatim from the package.
- Updates the 3 consumers (MonacoEditor.tsx, CollectionView.tsx,
  GetPerformanceInsightsCard.tsx) to import from the new local path.
- Updates the accessibility-aria-expert skill doc to reference the
  in-extension path.

The package still exports Announcer at this commit so the surface change
is non-breaking; the actual deletion follows in a subsequent commit.
Relocates the context-menu-prevention hook out of the
@microsoft/vscode-ext-react-webview package and back into the extension
where it is used. The hook's allowlist hardcodes Monaco-specific CSS
selectors, so it is application-flavoured UX policy rather than generic
webview infrastructure.

- Adds src/webviews/components/useSelectiveContextMenuPrevention.ts —
  content copied verbatim from the package.
- Updates the 2 consumers (CollectionView.tsx, documentView.tsx) to
  split the package import and pull the hook from the new local path.

The package still exports the hook at this commit so the surface change
is non-breaking; the actual deletion follows in a subsequent commit.
Removes the accessibility helper (Announcer) and the Monaco-flavoured
context-menu hook (useSelectiveContextMenuPrevention) from the package.
Both were app-level concerns rather than transport infrastructure:

- Announcer is a generic WCAG-compliant screen-reader live region. A11y
  belongs with the consumer or a dedicated a11y library, not bundled
  into a webview transport package.
- useSelectiveContextMenuPrevention hardcodes Monaco-specific CSS
  selectors in its allowlist. It is DocumentDB's UX policy for one
  editor, not generic webview behaviour.

The two hooks live in the extension now (re-homed in the preceding
commits under src/webviews/components/accessibility/ and
src/webviews/components/useSelectiveContextMenuPrevention.ts).

What remains in the package after this commit is the strict transport
surface:

  Webview side:
    WebviewContext, WithWebviewContext, useConfiguration, useTrpcClient,
    vscodeLink, TrpcClient (+ message types)
  Extension side (/server):
    WebviewController, router, publicProcedure,
    publicProcedureWithTelemetry, createMiddleware, createCallerFactory,
    BaseRouterContext, TelemetryContext, WithTelemetry

Also:
- package.json description drops "and accessibility helpers".
- README's "What's inside" drops the Accessibility helper bullet and
  useSelectiveContextMenuPrevention from React hooks; entry-point
  example drops Announcer; "Scope" is rewritten to "only the webview
  transport, nothing UX-flavoured".

Marked breaking (!) per Conventional Commits because the package's
exported surface shrinks. Acceptable: the package is preview, not yet
published.
Adds three sections to the package README to match the production-grade
shape of the vscode-webview-starter-kit's documentation:

- "Architecture" — ASCII diagram of the extension-host / webview
  boundary, calling out the actual symbols on each side and pointing to
  the correct entry point (main vs /server). Establishes a shared
  mental model before the per-symbol "What's inside" rundown.
- "Advanced — Sharing a single tRPC client across components" — guidance
  for views that grow past ~10 components and benefit from a shared
  client via React context. Includes a complete TrpcProvider example
  and a pros/cons table vs the per-component default.
- "FAQ" — answers common consumer questions: why tRPC over raw
  postMessage; why two entry points; how to plug in a real telemetry
  sink (with @microsoft/vscode-azext-utils example); multiple panels;
  AbortSignal-driven cancellation; React-only scope of the package.

Sets the documentation bar for future API iterations: each new section
above is structural rather than narrative, so it stays useful while the
preview surface evolves.
Telemetry middleware, publicProcedureWithTelemetry, and the WithTelemetry helper now live in appRouter.ts alongside the router tree they configure. Per-view routers import these primitives from appRouter directly. trpc.ts is removed; five files become four.
…eclaring

The DocumentDB BaseRouterContext now intersects with the framework's BaseRouterContext (from @microsoft/vscode-ext-react-webview/server) and only adds dbExperience and webviewName. The telemetry? and signal? slots are inherited, so any future framework field lands here automatically.
…ntrollerBase

The same-name shadow of the framework WebviewController class made stack
traces and Ctrl+Click navigation ambiguous. The DocumentDB-side base class
is now WebviewControllerBase (file and class), while the framework class
keeps its WebviewController name. View controllers (CollectionViewController,
DocumentsViewController) now extend WebviewControllerBase. Breaking change
for in-tree consumers; the npm package surface is unchanged.
Lifts every customizable runtime value in src/webviews/api/ into a single
WEBVIEW_CONFIG object: telemetry namespace and prefixes, bundle layout, and
dev-server host. appRouter.ts and WebviewControllerBase.ts now read from it
instead of declaring constants inline. The three telemetry prefixes are
derived from one TELEMETRY_NAMESPACE constant, so renaming the namespace is
a one-line change.
…Integration/

The folder is the local glue between this extension and
@microsoft/vscode-ext-react-webview; "api" was too generic and overloaded
with the extension's public API, REST/data API, and the package API.
webviewIntegration accurately describes the role and the contents
(extension-host controller, router, browser-side hook). All consumer imports
are updated. Skill docs (webview-trpc-messaging, react-webview-architecture)
are updated to match. Breaking change for in-tree consumers; npm package
surface is unchanged.
Renames src/webviews/webviewIntegration/ to src/webviews/_integration/.
The underscore prefix is the conventional "infrastructure / not feature
code" signal in monorepos, and it sorts before feature folders
(components/, documentdb/, ...) in VS Code's file explorer. With api/
the folder was first by alphabetical accident; with webviewIntegration/
it sorted at the bottom. _integration restores the desired top placement
while staying honest about the role (local glue between this extension
and @microsoft/vscode-ext-react-webview).

Breaking change for in-tree consumers; the npm package surface is
unchanged. All imports across src/ and the skill docs under .github/
are updated.
…riptions

Adds a small typed async-iterable utility under the /server entry that
bridges push-style domain events (event emitters, driver callbacks,
completion notifiers) into tRPC subscription procedures. The shape is:

  - producer side: imperative `emit(event)` or `emit(type, payload)`
    with discriminated-union narrowing on the second overload
  - consumer side: `for await (const event of sink)` from inside a
    subscription generator
  - `close()` to complete the iterator on panel disposal

The class is single-consumer by design and matches tRPC's 1-to-1
subscription model. Events emitted before a consumer attaches are
buffered; events emitted after close() are silently dropped.

Exports added to @microsoft/vscode-ext-react-webview/server:
  - TypedEventSink (class)
  - DiscriminatedEvent, EventOfType<T, K>, UntypedEventEmitter (helper types)

README gets a new Advanced subsection ("Push events from the extension
host to the webview") that shows the full pattern: event union, events
router file, producer call site, and webview-side subscribe. The
events-router file convention is recommended as the place to put
push-event procedures.

Includes a 7-case Jest suite covering buffering, parked consumer
delivery, close semantics, and single-consumer enforcement.
Adds an opt-in webview-side error observer to
@microsoft/vscode-ext-react-webview. New public surface (default entry):

  - errorLink<TRouter>(onError): TRPCLink<TRouter>
  - ErrorHandler type
  - UseTrpcClientOptions interface
  - useTrpcClient<TRouter>(options?) - existing zero-arg call sites
    continue to work; passing { onError } installs the link

Semantics:

  - The handler fires for query and mutation errors, in addition to the
    normal tRPC error flow. Call-site .catch handlers still run and
    still receive the error - the link observes, it does not swallow.
  - Subscription errors are intentionally not forwarded. Subscriptions
    have their own .subscribe({ onError }) hook that gives the call
    site enough control; forwarding here would surface errors twice.
  - Non-Error rejections are normalised to Error instances before being
    handed to the consumer.

Use it for single-place webview-side error UX: an ARIA Announcer, a
FluentUI Toaster, a telemetry sink.

README gets a new Advanced subsection ("Webview-side error observer")
covering both call paths (option on useTrpcClient, or composing
errorLink manually). errorLink is also added to "What's inside".

Includes a 6-case Jest suite covering query/mutation forwarding,
subscription pass-through, non-Error normalisation, and the happy path.
…ntroller

Adds a private safePostMessage(message) helper on the framework
WebviewController and routes every postMessage call site in the tRPC
dispatcher through it. The helper:

  - returns early if the controller is already disposed
  - wraps the postMessage call in try/catch to absorb synchronous throws
  - attaches a no-op catch to the returned Thenable so a late rejection
    does not surface as an unhandled promise rejection

The race this addresses: a subscription generator can yield one or two
more values after the panel was closed, because the per-subscription
AbortController only flags signal.aborted between yields. Without the
guard, those late yields hit postMessage on a disposed Webview, which
either throws synchronously or rejects the returned Thenable depending
on the VS Code version. The result was noisy uncaught exceptions in
the extension host. They were never real bugs, but they confused
debugging.

dispose() already aborts all _activeOperations and _activeSubscriptions
so server-side procedures stop early; this change is purely defensive
for the window between abort() and the next signal.aborted check.

No behaviour change for valid messages. Six call sites updated:
subscription value yield, subscription complete, subscription error,
subscription outer-catch, default result, default error.
Restructures the package README to be self-contained:

  - new "Quick start" section walks through a four-file minimum (router,
    controller, webview entry, view component) so a first-time consumer
    can read the package surface in one pass without bouncing between
    repos
  - new "Starter kit and reference consumers" section makes the
    tnaum-ms/vscode-webview-starter-kit link prominent and lists what
    the kit covers that the package intentionally does not
    (build configuration, accessibility helpers, Monaco wiring, demo
    view), plus a freshness caveat that the kit may lag a release
    while the surface stabilises
  - stale `../api/appRouter` import path in the shared-client example
    updated to the current `../_integration/appRouter` shape
  - all 16 em-dashes replaced with plain hyphens, colons, or periods

README-only, no code changes.
Copilot AI review requested due to automatic review settings May 22, 2026 15:15
@tnaum-ms tnaum-ms requested a review from a team as a code owner May 22, 2026 15:15
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Hardens the preview surface of the new @microsoft/vscode-ext-react-webview workspace package (typed push-event bridge, optional webview-side error observer, safer panel messaging) while reshaping this repo’s consumer integration layer under src/webviews/_integration/ to keep the in-tree usage aligned with the package’s evolving API.

Changes:

  • Added new package utilities and entrypoints (TypedEventSink, errorLink, /server split) plus safer postMessage handling in the framework WebviewController.
  • Reshaped DocumentDB’s consumer glue layer into src/webviews/_integration/ (router + controller base + config + typed client hook) and rewired webviews to use it.
  • Re-homed a11y/helpers (e.g., Announcer, selective context-menu prevention) into src/webviews/components/ and updated imports/docs.

Reviewed changes

Copilot reviewed 52 out of 58 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
tsconfig.json Adds workspace reference for new package
src/webviews/index.tsx Uses package WithWebviewContext; updates registry import
src/webviews/documentdb/documentView/documentView.tsx Switches to _integration client + component util paths
src/webviews/documentdb/documentView/documentsViewRouter.ts Imports router primitives/context from _integration
src/webviews/documentdb/documentView/documentsViewController.ts Moves to WebviewControllerBase
src/webviews/documentdb/collectionView/components/toolbar/ToolbarViewNavigation.tsx Updates useTrpcClient import path
src/webviews/documentdb/collectionView/components/toolbar/ToolbarTableNavigation.tsx Updates useTrpcClient import path
src/webviews/documentdb/collectionView/components/toolbar/ToolbarMainView.tsx Uses package useConfiguration + _integration tRPC
src/webviews/documentdb/collectionView/components/queryInsightsTab/QueryInsightsTab.tsx Uses package useConfiguration + _integration tRPC
src/webviews/documentdb/collectionView/components/queryInsightsTab/components/queryPlanSummary/QueryPlanSummary.tsx Updates useTrpcClient import path
src/webviews/documentdb/collectionView/components/queryInsightsTab/components/optimizationCards/TipsCard.tsx Updates useTrpcClient import path
src/webviews/documentdb/collectionView/components/queryInsightsTab/components/optimizationCards/custom/GetPerformanceInsightsCard.tsx Moves Announcer import to components
src/webviews/documentdb/collectionView/components/queryEditor/QueryEditor.tsx Uses package useConfiguration + _integration tRPC
src/webviews/documentdb/collectionView/collectionViewRouter.ts Imports router primitives/context from _integration
src/webviews/documentdb/collectionView/collectionViewController.ts Moves to WebviewControllerBase
src/webviews/documentdb/collectionView/CollectionView.tsx Uses package useConfiguration; moves a11y/util imports
src/webviews/components/useSelectiveContextMenuPrevention.ts New context-menu prevention hook (Monaco allowlist)
src/webviews/components/MonacoEditor.tsx Moves Announcer import to components
src/webviews/components/accessibility/index.ts Consolidates value+type export style
src/webviews/components/accessibility/Announcer.tsx New ARIA live-region announcer component
src/webviews/api/webview-client/useTrpcClient.ts Removed (replaced by package + _integration)
src/webviews/api/extension-server/trpc.ts Removed (moved/merged into _integration/appRouter.ts)
src/webviews/api/configuration/appRouter.ts Removed (replaced by _integration/appRouter.ts)
src/webviews/_integration/WebviewRegistry.ts Updated relative imports after folder move
src/webviews/_integration/WebviewControllerBase.ts New DocumentDB-tuned controller base over package controller
src/webviews/_integration/useTrpcClient.ts New typed wrapper hook for package useTrpcClient
src/webviews/_integration/README.md New signpost for integration layer responsibilities
src/webviews/_integration/configuration.ts New consumer-owned knobs (telemetry/bundle/devhost)
src/webviews/_integration/appRouter.ts New root router + telemetry middleware + shared procedures
packages/vscode-ext-react-webview/tsconfig.json New composite TS project for workspace package
packages/vscode-ext-react-webview/src/webview-client/WebviewContext.tsx New shared webview context/provider
packages/vscode-ext-react-webview/src/webview-client/vscodeLink.ts Makes vscodeLink generic + exports options type
packages/vscode-ext-react-webview/src/webview-client/vscodeLink.test.ts Updates tests for new generic vscodeLink
packages/vscode-ext-react-webview/src/webview-client/useTrpcClient.ts New hook with optional onError observer
packages/vscode-ext-react-webview/src/webview-client/useConfiguration.ts Docstring cleanup
packages/vscode-ext-react-webview/src/webview-client/index.ts New browser-surface barrel export
packages/vscode-ext-react-webview/src/webview-client/errorLink.ts New link to observe query/mutation errors
packages/vscode-ext-react-webview/src/webview-client/errorLink.test.ts Tests for errorLink behavior
packages/vscode-ext-react-webview/src/server.ts New server entrypoint for extension-host surface
packages/vscode-ext-react-webview/src/index.ts New browser-only main entrypoint
packages/vscode-ext-react-webview/src/extension-server/WebviewController.ts Adds options injection + safe postMessage routing
packages/vscode-ext-react-webview/src/extension-server/TypedEventSink.ts New async-iterable event sink for subscriptions
packages/vscode-ext-react-webview/src/extension-server/TypedEventSink.test.ts Tests for TypedEventSink
packages/vscode-ext-react-webview/src/extension-server/trpc.ts New tRPC init + middleware factory + default telemetry
packages/vscode-ext-react-webview/src/extension-server/index.ts New server-surface barrel export
packages/vscode-ext-react-webview/src/extension-server/BaseRouterContext.ts New base ctx contract (telemetry/signal)
packages/vscode-ext-react-webview/README.md New package docs + quick start + advanced usage
packages/vscode-ext-react-webview/package.json New workspace package manifest + exports map
packages/vscode-ext-react-webview/jest.config.js New package Jest config
package.json Adds workspace dependency on new package
package-lock.json Adds workspace link for new package
l10n/bundle.l10n.json Removes unused localization key
jest.config.js Adds new package to Jest projects
docs/ai-and-plans/PRs/webview-api-package-preview-hardening.md Adds PR design/decision doc
.github/skills/webview-trpc-messaging/SKILL.md Updates skill doc to new integration shape
.github/skills/react-webview-architecture/SKILL.md Updates architecture doc references
.github/skills/react-webview-architecture/references/REACT_ARCHITECTURE_GUIDELINES.md Updates guideline import paths
.github/skills/accessibility-aria-expert/SKILL.md Updates Announcer import guidance
Comments suppressed due to low confidence (2)

packages/vscode-ext-react-webview/src/extension-server/WebviewController.ts:508

  • dispose() fires this._onDisposed but no longer disposes the EventEmitter itself. Since _onDisposed isn't in _disposables, this can retain listeners longer than necessary. Consider calling this._onDisposed.dispose() (typically after .fire()) to release resources.
    packages/vscode-ext-react-webview/src/extension-server/WebviewController.ts:211
  • In handleSubscriptionMessage, _activeSubscriptions.set(message.id, abortController) happens before looking up/awaiting the subscription procedure. If procedure lookup fails or await procedure(...) throws before the async loop starts, the entry is never removed (cleanup currently only happens in the inner IIFE’s finally). Add cleanup in the outer catch/finally path (or defer inserting into the map until after the subscription is successfully started) to avoid leaking AbortControllers and stale subscription IDs.

Comment thread l10n/bundle.l10n.json
Comment thread packages/vscode-ext-react-webview/README.md Outdated
tnaum-ms added 2 commits May 22, 2026 15:28
If a subscription procedure was misnamed or its initial await threw,
the abort controller had already been inserted into _activeSubscriptions
and never removed (the inner finally only runs after the consumer task
starts). Repeated invalid subscription calls on a long-lived panel could
therefore leak stale (id, AbortController) entries.

Defer the Map.set until after the iterator is obtained so an early
failure falls through to the outer catch without ever inserting an entry.
The inner finally remains the sole cleanup site for the successful path.

Refs PR #676 review (MEDIUM-2).
@tnaum-ms
Copy link
Copy Markdown
Collaborator Author

tnaum-ms commented May 22, 2026

Fix: MEDIUM-2 — Subscription leak on early failure

handleSubscriptionMessage used to insert the abort controller into _activeSubscriptions before procedure lookup and the initial await procedure(...). If either failed, the outer catch posted the tRPC error but never deleted the entry, because the inner finally that does the cleanup only runs after the consumer IIFE starts. Repeated invalid subscription calls on a long-lived panel could therefore leave stale (id, AbortController) entries behind for the panel's lifetime.

Fix: defer _activeSubscriptions.set until after the iterator is in hand. Early failures fall through to the outer catch without ever inserting an entry; the inner finally remains the sole cleanup site for the successful path.

Commit: 4d9cdaa
File: packages/vscode-ext-react-webview/src/extension-server/WebviewController.ts

tnaum-ms added 3 commits May 22, 2026 18:47
The only runtime use of @vscode/l10n inside the package was a single
'Procedure not found: {name}' message in WebviewController, used for a
framework-internal protocol error that consumers cannot translate. The
bundle key was already removed from l10n/bundle.l10n.json earlier in
this PR, and l10n/scripts/build.l10n.mjs only scans ./src so it would
never be regenerated from the package source.

Make the message a plain Error string and remove @vscode/l10n from the
package's peer dependencies, the README peer-dep table, and the lockfile
entry. The HTML template in WebviewController still injects
vscode.l10n.bundle from the host into the webview — that uses VS Code's
already-loaded bundle, not the peer.

Refs PR #676 review (MEDIUM-3).
The install paragraph previously listed react-dom alongside the real
peers (react, @trpc/*). The package itself never imports react-dom; only
consumer app shells do (the README quick-start example uses createRoot
from react-dom/client because that is how a typical React app boots a
webview, not because this package depends on it). The peer-dep table
and package.json have always been consistent with that.

Drop react-dom from the install sentence and add a one-liner explaining
that react-dom is a transitive concern of the consumer's DOM shell, not
a peer of this package.

Refs PR #676 review (LOW-1).
…disposed

The _onDisposed EventEmitter was created at construction time and fired
during dispose(), but its own .dispose() was never called, so its
internal listener array survived for the lifetime of the extension host.
Low-impact (controllers don't outlive their panels), but easy to make
the lifecycle complete.

Register the emitter in _disposables at construction so the existing
_disposables.forEach(d => d.dispose()) loop in dispose() tears it down
right after _onDisposed.fire(). Order matters: fire() runs first, then
the disposables array (including the emitter) is disposed, so subscribers
still see the notification before the emitter goes away.

Refs PR #676 review (LOW-2).
@tnaum-ms
Copy link
Copy Markdown
Collaborator Author

tnaum-ms commented May 22, 2026

Fix: LOW-2 — _onDisposed event emitter never released

_onDisposed was created at construction time and fired during dispose(), but its own .dispose() was never called, so its internal listener list survived for the lifetime of the extension host. Low-impact (controllers don't outlive their panels), but easy to make the lifecycle complete.

Fix: register the emitter in _disposables at construction. Order matters and is preserved: dispose() fires the event first, then iterates _disposables (which now includes the emitter), so subscribers still see the notification before the emitter is torn down.

Commit: 588a347

The createMiddleware example imported createMiddleware, publicProcedure,
and BaseRouterContext from '@microsoft/vscode-ext-react-webview', but
the package's main entry intentionally exports only the browser/webview-
client surface. These server symbols live under the '/server' subpath
(matching the README example and the actual exports map in package.json).

Refs PR #676 review (LOW-4).
@tnaum-ms
Copy link
Copy Markdown
Collaborator Author

tnaum-ms commented May 22, 2026

Fix: LOW-4 — JSDoc imported server symbols from the main entry

The createMiddleware JSDoc example in packages/vscode-ext-react-webview/src/extension-server/trpc.ts imported createMiddleware, publicProcedure, and BaseRouterContext from '@microsoft/vscode-ext-react-webview'. The main entry intentionally exports only the browser/webview-client surface; these server symbols live under the /server subpath, matching the README example and the actual exports map in package.json.

Fix: changed the JSDoc import to '@microsoft/vscode-ext-react-webview/server'. One-line edit.

Commit: edd1274

The PR that renamed the workspace package from @microsoft/vscode-webview-api
to @microsoft/vscode-ext-react-webview left a stale 'extraneous' entry for
packages/vscode-webview-api in the lockfile. Regenerate cleanly with
'rm -rf node_modules package-lock.json && npm install' so the lockfile
reflects only the workspace packages that actually exist.

The large diff is mostly alphabetical reordering of the resolved package
entries — the dependency graph itself is unchanged. Verified: build, lint,
prettier, and jest (96 suites, 1948 tests) all green after the regenerate.

Refs PR #676 review (LOW-3).
@tnaum-ms
Copy link
Copy Markdown
Collaborator Author

tnaum-ms commented May 22, 2026

Fix: LOW-3 — Stale workspace entry in lockfile

The earlier rename of the workspace package (@microsoft/vscode-webview-api@microsoft/vscode-ext-react-webview) left an extraneous: true entry for packages/vscode-webview-api in package-lock.json. The directory no longer exists in the workspace.

Fix: regenerated the lockfile cleanly:

rm -rf node_modules package-lock.json
npm install

The diff is large (~12k/~10k lines) but almost entirely alphabetical reordering of resolved package entries; the dependency graph itself is unchanged. Verified post-regen:

  • npm run build green (all workspaces)
  • npm run lint clean
  • npm run prettier-fix no changes
  • npx jest --no-coverage — 96 suites, 1948 tests passing
  • vscode-webview-api no longer present in the lockfile

Commit: 00f3664

tnaum-ms added 2 commits May 22, 2026 19:25
Until now only 'npm run build' and 'npm run jesttest' ran the workspace
build chain ahead of time (via prebuild / prejesttest). Webpack-driven
scripts — webpack-dev, webpack-prod, watch:ext, watch:views — went
straight to the bundler, which resolves @microsoft/vscode-ext-react-webview
to packages/vscode-ext-react-webview/dist per its package.json 'exports'
field. On a clean checkout or after the package source changed without
'npm run build', that resolved to missing or stale output and silently
bundled the wrong code.

Add pre* hooks so every bundle/watch path runs
'npm run build --workspaces --if-present' first:

  prewebpack-dev, prewebpack-prod, prewatch:ext, prewatch:views

'npm run package' and 'npm run package-prerelease' inherit the safety
transitively because they call 'npm run webpack-prod', which now has its
own pre-hook. Verified by removing packages/vscode-ext-react-webview/dist
and running 'npm run webpack-prod' — the dist was recreated by the pre-
hook before webpack started.

Aligns in-repo dev with the npm-consumer reality: every consumer of the
package resolves it through 'dist', so always build dist first. When the
package leaves this repo, these hooks can be removed in one commit.

Refs PR #676 review (HIGH-1).
Before, both subscription.stop and panel disposal only aborted the per-
operation AbortController. That signal is cooperative: it cannot unblock
an iterator parked on next(), which is exactly the shape used by
TypedEventSink — consumers wait on 'new Promise((resolve) => …)' for the
next emit() or close(). When a client unsubscribed mid-stream while the
panel stayed alive, the procedure's generator stayed parked indefinitely.
vscode-cosmosdb hits the same shape and works around it by having every
panel call sink.close() in its own dispose(); we want the framework to
handle this instead of relying on producer discipline.

Two changes:

1. TypedEventSink iterator gains a real return() implementation. It
   marks the sink closed, clears any buffered events, resolves a pending
   next() with { done: true }, and releases the single-consumer guard
   so a fresh for-await can iterate again (and will see done: true).
   This makes 'break' inside a for-await over a sink release the
   pending promise per the JS iterator protocol — no host cooperation
   needed for the natural case either.

2. WebviewController now tracks both the AbortController and the live
   AsyncIterator per subscription. handleSubscriptionMessage normalizes
   the procedure result (AsyncIterable | AsyncIterator) to a single
   iterator, drives it via an explicit next() loop instead of for-await,
   and stores both in _activeSubscriptions. handleSubscriptionStopMessage
   and dispose() now abort *and* call iterator.return(); return()
   propagates through the procedure's async generator into any inner
   for-await over an event sink, which settles the parked promise and
   ends the streaming task naturally.

README 'Can I cancel a long-running query or subscription?' now states
the real lifecycle (abort + iterator.return propagation). The event-sink
guidance is updated: producers should still call sink.close() when their
own lifetime ends, but the framework no longer depends on it for
unsubscribe cleanup.

Tests: four new TypedEventSink tests for return() — releases a parked
next(), completes a for-await on break, drops buffered events on
return(), and idempotency. Full suite 1952 passing.

Refs PR #676 review (MEDIUM-1).
@tnaum-ms
Copy link
Copy Markdown
Collaborator Author

tnaum-ms commented May 22, 2026

Fix: HIGH-1 — Bundle scripts now always rebuild workspace packages first

Until now only npm run build and npm run jesttest ran the workspace build chain ahead of time (via the existing prebuild / prejesttest hooks). The webpack-driven scripts went straight to the bundler, which resolves @microsoft/vscode-ext-react-webview to packages/vscode-ext-react-webview/dist per the package's exports field. On a clean checkout — or after the package source changed without anyone re-running npm run build — that resolved to missing or stale output and silently bundled the wrong code.

Fix: added pre* hooks so every bundle/watch path runs npm run build --workspaces --if-present first:

  • prewebpack-dev
  • prewebpack-prod
  • prewatch:ext
  • prewatch:views

npm run package and npm run package-prerelease inherit the safety transitively because they call npm run webpack-prod, which now has its own pre-hook.

Verified by deleting packages/vscode-ext-react-webview/dist and running npm run webpack-prod — the package's dist was recreated by prewebpack-prod before webpack started bundling. When the package eventually leaves this repo, these four lines come out in one commit.

Commit: 5e67c14

@tnaum-ms
Copy link
Copy Markdown
Collaborator Author

tnaum-ms commented May 22, 2026

Fix: MEDIUM-1 — Subscriptions now release iterators parked on next()

subscription.stop and panel disposal previously only aborted the per-operation AbortController. That signal is cooperative — it cannot unblock an iterator parked on next(), which is exactly the shape used by TypedEventSink (consumers park on new Promise((resolve) => …) waiting for the next emit or close). When a client unsubscribed mid-stream while the panel stayed alive, the procedure's generator stayed parked indefinitely.

Fix (two parts):

  1. TypedEventSink iterator gains a real return() implementation that marks the sink closed, drops buffered events, resolves a pending next() with { done: true }, and releases the single-consumer guard. This makes break inside a for await over a sink release the pending promise per the JS iterator protocol — no host cooperation needed for the natural case either.
  2. WebviewController now tracks both the AbortController and the live AsyncIterator per subscription. handleSubscriptionMessage normalizes the procedure result (AsyncIterable | AsyncIterator) to a single iterator, drives it via an explicit next() loop instead of for await, and stores both in _activeSubscriptions. handleSubscriptionStopMessage and dispose() now abort and call iterator.return(); return() propagates through the procedure's async generator into any inner for await over an event sink, settling the parked promise and ending the streaming task naturally.

README "Can I cancel a long-running query or subscription?" now states the real lifecycle (abort + iterator.return propagation). The event-sink guidance is updated: producers should still call sink.close() when their own lifetime ends, but the framework no longer depends on it for unsubscribe cleanup.

Tests: four new TypedEventSink tests for return() — releases a parked next(), completes a for await on break, drops buffered events on return(), and idempotency. Full suite 1952 passing (was 1948).

I'll file a follow-up on tnaum-ms/vscode-webview-starter-kit so the starter kit (and the vscode-cosmosdb migration when it lands) picks up the new lifecycle contract.

Commit: d21d55b

The package-lock.json regeneration in 00f3664 was done with npm 10.9.4, which
resolves @swc/cli's optional peer dependency on chokidar@^5.0.0 differently
than npm 10.9.3. The result was a lockfile that npm install accepts on either
version, but npm ci on 10.9.3 (which is what CI uses, bundled with Node 22.18)
rejects with:

  Invalid: lock file's chokidar@3.6.0 does not satisfy chokidar@5.0.0
  Missing: chokidar@3.6.0 from lock file

Restore the pre-00f36644 lockfile, which has the chokidar@5.0.0 entry at the
root marked as optional/peer (resolved from @swc/cli) and a nested chokidar@3.6.0
under @vscode/test-cli. This is the shape npm 10.9.3 expects.

The stale 'extraneous: true' entry for the renamed packages/vscode-webview-api
workspace returns as a side effect; it is cosmetic and does not affect install
behaviour. A clean regen requires running npm install with the same npm version
CI uses, which is npm 10.9.3 bundled with Node 22.18.0 (per .nvmrc).

Verified with Node 22.18.0 / npm 10.9.3:
- npm ci passes (1792 packages)
- npm run build passes
- npm run lint clean
- npm run prettier-fix no changes
- npx jest --no-coverage: 96 suites / 1952 tests passing
@tnaum-ms
Copy link
Copy Markdown
Collaborator Author

Fix: CI npm ci failure caused by 00f3664 lockfile regen

The CI build for this PR started failing after commit 00f3664 (the LOW-3 lockfile cleanup) with:

Invalid: lock file's chokidar@3.6.0 does not satisfy chokidar@5.0.0
Missing: chokidar@3.6.0 from lock file
Invalid: lock file's readdirp@3.6.0 does not satisfy readdirp@5.0.0

Root cause: I regenerated the lockfile locally with npm 10.9.4 (bundled with Node 22.21.1). CI runs npm 10.9.3 (bundled with Node 22.18.0, per .nvmrc). The two versions differ in how they resolve @swc/cli's optional peer dependency on chokidar@^5.0.0:

  • npm 10.9.4 hoists chokidar@3.6.0 (from @vscode/test-cli) to root and omits the optional peer entry.
  • npm 10.9.3 expects chokidar@5.0.0 at root (the optional peer) plus a nested chokidar@3.6.0 under @vscode/test-cli.

npm install accepts either shape on either npm version, but npm ci strictly validates the lockfile against the npm version's resolution algorithm — so the 10.9.4-generated lockfile fails npm ci on 10.9.3.

Fix (commit 28f38a2): restore the pre-00f36644 lockfile. That lockfile has the chokidar@5.0.0 optional-peer entry at root and is the shape npm 10.9.3 expects. The cosmetic extraneous: true entry for packages/vscode-webview-api (the old name before the rename) comes back as a side effect — it does not affect install behaviour.

Lesson: when regenerating package-lock.json, always run with the npm version pinned by .nvmrc / the CI environment, not the host's default. For this repo:

fnm use 22.18.0   # or: nvm use
rm -rf node_modules package-lock.json
npm install

Verified locally with Node 22.18.0 / npm 10.9.3:

  • npm ci passes (1792 packages)
  • npm run build passes
  • npm run lint clean
  • npm run prettier-fix no changes
  • npx jest --no-coverage: 96 suites / 1952 tests passing

@github-actions
Copy link
Copy Markdown
Contributor

✅ Code Quality Checks

Check Status How to fix
Localization (l10n) ✅ Passed
ESLint ✅ Passed
Prettier formatting ✅ Passed

This comment is updated automatically on each push.

@github-actions
Copy link
Copy Markdown
Contributor

📦 Build Size Report

Metric Base (main) PR Delta
VSIX (vscode-documentdb-0.8.0.vsix) 7.51 MB 7.53 MB ⬆️ +25 KB (+0.3%)
Webview bundle (views.js) 5.79 MB 5.88 MB ⬆️ +91 KB (+1.5%)

Download artifact · updated automatically on each push.

@tnaum-ms tnaum-ms merged commit 705b17e into main May 26, 2026
8 checks passed
@tnaum-ms tnaum-ms deleted the dev/tnaum/webview-api-package branch May 26, 2026 10:00
@tnaum-ms tnaum-ms added this to the 0.8.1 milestone May 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Development

Successfully merging this pull request may close these issues.

3 participants