From 91e6fbeec4b9d509d7d24b50a19d6c6166127f96 Mon Sep 17 00:00:00 2001 From: Omri Katz <9701896+omridevk@users.noreply.github.com> Date: Sat, 15 Aug 2026 23:02:23 +0300 Subject: [PATCH 1/7] fix(site): #502 try-it-live primary CTA + pre-mount click race Promotes TryLiveButton to the hero's sole primary CTA and closes the pre-mount click race root-caused during this PR: embed's mount() promise was resolving before the widget's root route onMount had registered its conciv:open-panel listener, so an early click (or a dismissed visitor's only path back in) was silently dropped even after switching mount-live-widget.ts off the fire-and-forget mountConciv() call. packages/embed/src/mount-impl.tsx now threads an interactive signal through apps/conciv's router context, resolved from __root.tsx's onMount once the listener is live, and mount() does not resolve until that fires. Adds a mobile explanatory line in place of the previous empty gap. Co-Authored-By: Claude Fable 5 --- .changeset/try-live-panel-ready-contract.md | 5 ++ apps/conciv/src/app/context.ts | 5 ++ apps/conciv/src/router.tsx | 3 + apps/conciv/src/routes/__root.tsx | 4 ++ apps/conciv/test/helpers/pane-harness.tsx | 1 + apps/site/src/components/landing/hero.tsx | 11 +++- .../components/landing/try-live-button.tsx | 64 +++++++++++++++++-- apps/site/src/lib/mount-live-widget.ts | 5 +- apps/site/src/lib/try-state.ts | 6 ++ apps/site/test/live-connect.it.test.ts | 50 +++++++++++---- apps/site/test/mobile-gating.it.test.ts | 2 + apps/site/test/try-state.test.ts | 44 +++++++++++++ packages/embed/src/mount-impl.tsx | 19 +++++- .../embed/tests/e2e/mount-ready.it.test.ts | 36 +++++++++++ 14 files changed, 231 insertions(+), 24 deletions(-) create mode 100644 .changeset/try-live-panel-ready-contract.md create mode 100644 apps/site/test/try-state.test.ts create mode 100644 packages/embed/tests/e2e/mount-ready.it.test.ts diff --git a/.changeset/try-live-panel-ready-contract.md b/.changeset/try-live-panel-ready-contract.md new file mode 100644 index 000000000..73bb42f43 --- /dev/null +++ b/.changeset/try-live-panel-ready-contract.md @@ -0,0 +1,5 @@ +--- +'@conciv/embed': patch +--- + +`mount(el)` now resolves only once the widget can actually honor `open()`/`conciv:open-panel` — previously the returned promise settled as soon as the app's boot sequence finished computing, which raced ahead of the root route's `onMount` (where the open-panel listener registers). Any embedder that opens the panel immediately after `mount()` resolves — including a landing page's "Try it live" button that dispatches an early click before the widget bundle finishes loading — no longer has that open silently dropped. diff --git a/apps/conciv/src/app/context.ts b/apps/conciv/src/app/context.ts index 708b836b1..4fdc60a91 100644 --- a/apps/conciv/src/app/context.ts +++ b/apps/conciv/src/app/context.ts @@ -28,6 +28,7 @@ export type AppContextValue = { grabProvider?: GrabProvider connectionGeneration: () => number apiBase: () => string + notifyInteractive: () => void } export const AppContext = createContext() @@ -101,3 +102,7 @@ export function useConnectionGeneration(): () => number { export function useApiBase(): () => string { return useAppScope('useApiBase', (app) => app.apiBase) } + +export function useNotifyInteractive(): () => void { + return useAppScope('useNotifyInteractive', (app) => app.notifyInteractive) +} diff --git a/apps/conciv/src/router.tsx b/apps/conciv/src/router.tsx index c027ed57c..2bcfa9ff3 100644 --- a/apps/conciv/src/router.tsx +++ b/apps/conciv/src/router.tsx @@ -33,6 +33,7 @@ export type ConcivRouterContext = { apiBase: () => string connectionGeneration: () => number disposeInstances: () => void + notifyInteractive: () => void } export type ConcivRouterConfig = { @@ -48,6 +49,7 @@ export type ConcivRouterConfig = { grabProvider?: GrabProvider apiBase?: () => string connectionGeneration?: () => number + notifyInteractive?: () => void } function disposeExtensionInstances(instances: ExtensionInstance[]): void { @@ -123,6 +125,7 @@ export function createConcivRouter(config: ConcivRouterConfig) { apiBase, connectionGeneration: config.connectionGeneration ?? (() => 0), disposeInstances, + notifyInteractive: config.notifyInteractive ?? (() => {}), }, }) } diff --git a/apps/conciv/src/routes/__root.tsx b/apps/conciv/src/routes/__root.tsx index 055dbbd53..0d15ffa90 100644 --- a/apps/conciv/src/routes/__root.tsx +++ b/apps/conciv/src/routes/__root.tsx @@ -20,6 +20,7 @@ import { useAppData, useConnected, useLayers, + useNotifyInteractive, useSettings, useSuppressed, type AppContextValue, @@ -113,6 +114,7 @@ function RootComponent() { grabProvider: app.grabProvider, connectionGeneration: app.connectionGeneration, apiBase: app.apiBase, + notifyInteractive: app.notifyInteractive, } createEffect(() => { @@ -157,6 +159,7 @@ function RootChrome(props: { const layers = useLayers() const suppressed = useSuppressed() const connected = useConnected() + const notifyInteractive = useNotifyInteractive() const router = useRouter() const matchRoute = useMatchRoute() const panelMatch = matchRoute({to: '/panel/$sessionId', fuzzy: true}) @@ -270,6 +273,7 @@ function RootChrome(props: { makeEventListener(window, 'conciv:open-panel', openFromHost) makeEventListener(window, 'conciv:close-panel', closeFromHost) makeEventListener(window, 'conciv:toggle-panel', toggleFromHost) + notifyInteractive() }) const onKeyDown = (event: KeyboardEvent) => { diff --git a/apps/conciv/test/helpers/pane-harness.tsx b/apps/conciv/test/helpers/pane-harness.tsx index f74384488..384a4979b 100644 --- a/apps/conciv/test/helpers/pane-harness.tsx +++ b/apps/conciv/test/helpers/pane-harness.tsx @@ -66,6 +66,7 @@ export function mountPane(options: PaneMountOptions, view: (pane: PaneContextVal connectMode: false, connectionGeneration: () => 0, apiBase: () => options.base, + notifyInteractive: () => {}, } const pane: PaneContextValue = { sessionId: () => options.sessionId, diff --git a/apps/site/src/components/landing/hero.tsx b/apps/site/src/components/landing/hero.tsx index c4169642b..d8f981fb6 100644 --- a/apps/site/src/components/landing/hero.tsx +++ b/apps/site/src/components/landing/hero.tsx @@ -1,5 +1,5 @@ import {Suspense, lazy} from 'react' -import {ClientOnly} from '@tanstack/react-router' +import {ClientOnly, Link} from '@tanstack/react-router' import {Badge} from '@/components/ui/badge' import {useIsMobile} from '@/lib/use-is-mobile' import {TryLiveButton} from './try-live-button' @@ -28,7 +28,14 @@ export function Hero() { run your tests, without ever leaving the thing you're building.

- {!isMobile && ( + {isMobile ? ( +

+ The live try-it flow needs a terminal, so it's desktop-only.{' '} + + Read the quick start → + +

+ ) : ( <> diff --git a/apps/site/src/components/landing/try-live-button.tsx b/apps/site/src/components/landing/try-live-button.tsx index face2d241..51c35a0a3 100644 --- a/apps/site/src/components/landing/try-live-button.tsx +++ b/apps/site/src/components/landing/try-live-button.tsx @@ -1,5 +1,6 @@ import {useSyncExternalStore} from 'react' import {Button} from '@/components/ui/button' +import {tryButtonLabel} from '@/lib/try-state' declare global { interface WindowEventMap { @@ -8,8 +9,15 @@ declare global { } let connected = false +let mounted = false +let pendingOpen = false +const pendingListeners = new Set<() => void>() -function subscribe(onChange: () => void): () => void { +function notifyPending(): void { + for (const listener of pendingListeners) listener() +} + +function subscribeConnection(onChange: () => void): () => void { const handler = (event: WindowEventMap['conciv:connection-changed']) => { connected = event.detail.connected onChange() @@ -18,22 +26,66 @@ function subscribe(onChange: () => void): () => void { return () => window.removeEventListener('conciv:connection-changed', handler) } +function subscribeMounted(onChange: () => void): () => void { + const handler = () => { + mounted = true + if (pendingOpen) { + pendingOpen = false + window.dispatchEvent(new Event('conciv:open-panel')) + notifyPending() + } + onChange() + } + window.addEventListener('conciv:widget-mounted', handler) + return () => window.removeEventListener('conciv:widget-mounted', handler) +} + +function subscribePending(onChange: () => void): () => void { + pendingListeners.add(onChange) + return () => pendingListeners.delete(onChange) +} + function useConcivConnected(): boolean { return useSyncExternalStore( - subscribe, + subscribeConnection, () => connected, () => false, ) } +function useWidgetMounted(): boolean { + return useSyncExternalStore( + subscribeMounted, + () => mounted, + () => false, + ) +} + +function usePendingOpen(): boolean { + return useSyncExternalStore( + subscribePending, + () => pendingOpen, + () => false, + ) +} + export function TryLiveButton() { const isConnected = useConcivConnected() - const open = () => window.dispatchEvent(new Event('conciv:open-panel')) + const isMounted = useWidgetMounted() + const isPending = usePendingOpen() + const open = () => { + if (isMounted) { + window.dispatchEvent(new Event('conciv:open-panel')) + return + } + pendingOpen = true + notifyPending() + } return (
-
) diff --git a/apps/site/src/lib/mount-live-widget.ts b/apps/site/src/lib/mount-live-widget.ts index c28c88871..0227f307d 100644 --- a/apps/site/src/lib/mount-live-widget.ts +++ b/apps/site/src/lib/mount-live-widget.ts @@ -23,7 +23,10 @@ export async function mountLiveWidget(opts: {widgetOpen: boolean; tryParam: bool import('@conciv/extension-try-it/client'), ]) if (document.querySelector('[data-conciv-root]')) return - embed.mountConciv([terminal.default, tryItModule.tryIt({token})]) + const root = document.createElement('div') + root.setAttribute('data-conciv-script-root', '') + document.body.appendChild(root) + await embed.createConciv({extensions: [terminal.default, tryItModule.tryIt({token})]}).mount(root) window.dispatchEvent(new Event('conciv:widget-mounted')) let hasBeenOpen = false diff --git a/apps/site/src/lib/try-state.ts b/apps/site/src/lib/try-state.ts index dbda08cab..3370c0a2e 100644 --- a/apps/site/src/lib/try-state.ts +++ b/apps/site/src/lib/try-state.ts @@ -10,3 +10,9 @@ export function shouldAutoOpen(opts: { export function shouldDismissOnClose(opts: {hasBeenOpen: boolean; connected: boolean}): boolean { return opts.hasBeenOpen && !opts.connected } + +export function tryButtonLabel(opts: {connected: boolean; pending: boolean}): string { + if (opts.connected) return 'Open agent panel' + if (opts.pending) return 'Opening…' + return 'Try it live' +} diff --git a/apps/site/test/live-connect.it.test.ts b/apps/site/test/live-connect.it.test.ts index c821f14cc..88f00be52 100644 --- a/apps/site/test/live-connect.it.test.ts +++ b/apps/site/test/live-connect.it.test.ts @@ -3,6 +3,7 @@ import {expect as expectLocator} from 'playwright/test' import {createFakeHarness} from '@conciv/harness-testkit' import {runConnect} from '@conciv/try' import type {Engine} from '@conciv/core/start' +import type {Page, Locator} from 'playwright/test' import {createSiteTest} from './site-fixture.js' const SITE_PORT = 8787 @@ -17,12 +18,34 @@ afterAll(async () => { await engine?.stop() }) +async function openLandingOnConnectSteps(page: Page): Promise { + await page.goto(ORIGIN, {waitUntil: 'domcontentloaded'}) + const panel = page.getByRole('dialog', {name: 'conciv chat agent'}) + await expectLocator(panel.getByText('Drive this page with your agent.')).toBeVisible({timeout: 20_000}) + return panel +} + +async function dismissAndReload(page: Page, panel: Locator): Promise { + await page.getByRole('button', {name: 'Minimize conciv chat'}).click() + await expectLocator(panel).toBeHidden({timeout: 10_000}) + await page.reload({waitUntil: 'domcontentloaded'}) + await expectLocator(page.getByRole('button', {name: 'Open conciv chat'})).toBeVisible({timeout: 20_000}) + expect(await panel.isVisible()).toBe(false) +} + test.describe('widget-native live connect on the built site', () => { + test('opens the panel for a click that lands before the widget bundle has mounted', async ({browser}) => { + const page = await browser.newPage() + await page.goto(`${ORIGIN}/?widget=false`, {waitUntil: 'domcontentloaded'}) + await page.getByRole('button', {name: /Try it live/i}).click() + await expectLocator(page.getByRole('dialog', {name: 'conciv chat agent'})).toBeVisible({timeout: 20_000}) + + await page.close() + }, 60_000) + test('boots the widget into connect steps and hands off in place to live chat', async ({browser}) => { const page = await browser.newPage() - await page.goto(ORIGIN, {waitUntil: 'domcontentloaded'}) - const panel = page.getByRole('dialog', {name: 'conciv chat agent'}) - await expectLocator(panel.getByText('Drive this page with your agent.')).toBeVisible({timeout: 20_000}) + const panel = await openLandingOnConnectSteps(page) const command = await panel .getByText(/^npx @conciv\/try --token \S+$/) @@ -65,19 +88,22 @@ test.describe('widget-native live connect on the built site', () => { test('remembers a pre-connect dismissal, and ?try=1 forces the panel open again', async ({browser}) => { const page = await browser.newPage() - await page.goto(ORIGIN, {waitUntil: 'domcontentloaded'}) - const panel = page.getByRole('dialog', {name: 'conciv chat agent'}) + const panel = await openLandingOnConnectSteps(page) + await dismissAndReload(page, panel) + + await page.goto(`${ORIGIN}/?try=1`, {waitUntil: 'domcontentloaded'}) await expectLocator(panel.getByText('Drive this page with your agent.')).toBeVisible({timeout: 20_000}) + await page.close() + }, 90_000) - await page.getByRole('button', {name: 'Minimize conciv chat'}).click() - await expectLocator(panel).toBeHidden({timeout: 10_000}) + test('the button reopens the panel after a dismiss-then-reload re-entry', async ({browser}) => { + const page = await browser.newPage() + const panel = await openLandingOnConnectSteps(page) + await dismissAndReload(page, panel) - await page.reload({waitUntil: 'domcontentloaded'}) - await expectLocator(page.getByRole('button', {name: 'Open conciv chat'})).toBeVisible({timeout: 20_000}) - expect(await panel.isVisible()).toBe(false) + await page.getByRole('button', {name: /Try it live/i}).click() + await expectLocator(panel).toBeVisible({timeout: 20_000}) - await page.goto(`${ORIGIN}/?try=1`, {waitUntil: 'domcontentloaded'}) - await expectLocator(panel.getByText('Drive this page with your agent.')).toBeVisible({timeout: 20_000}) await page.close() }, 90_000) }) diff --git a/apps/site/test/mobile-gating.it.test.ts b/apps/site/test/mobile-gating.it.test.ts index fcc45d2b2..90ddfa378 100644 --- a/apps/site/test/mobile-gating.it.test.ts +++ b/apps/site/test/mobile-gating.it.test.ts @@ -29,6 +29,8 @@ test.describe('landing gates the dev-only demo behind a non-mobile pointer', () await expectLocator(page.getByRole('button', {name: 'Copy install command'})).toHaveCount(0, {timeout: 20_000}) await expectLocator(page.getByRole('button', {name: /Try it live/i})).toHaveCount(0) await expectLocator(page.locator('[data-conciv-root]')).toHaveCount(0) + await expectLocator(page.getByText('desktop-only')).toBeVisible() + await expectLocator(page.getByRole('link', {name: 'Read the quick start →'})).toBeVisible() await context.close() }, 60_000) diff --git a/apps/site/test/try-state.test.ts b/apps/site/test/try-state.test.ts new file mode 100644 index 000000000..8749b3097 --- /dev/null +++ b/apps/site/test/try-state.test.ts @@ -0,0 +1,44 @@ +import {describe, expect, it} from 'vitest' +import {shouldAutoOpen, shouldDismissOnClose, tryButtonLabel} from '../src/lib/try-state' + +describe('shouldAutoOpen', () => { + it('opens for a fresh desktop visitor', () => { + expect(shouldAutoOpen({widgetOpen: true, tryParam: false, dismissed: false, widgetPresent: false})).toBe(true) + }) + + it('stays closed once the visitor dismissed the panel', () => { + expect(shouldAutoOpen({widgetOpen: true, tryParam: false, dismissed: true, widgetPresent: false})).toBe(false) + }) + + it('stays closed when the widget is already present', () => { + expect(shouldAutoOpen({widgetOpen: true, tryParam: false, dismissed: false, widgetPresent: true})).toBe(false) + }) +}) + +describe('shouldDismissOnClose', () => { + it('dismisses when a visitor closes an opened panel without connecting', () => { + expect(shouldDismissOnClose({hasBeenOpen: true, connected: false})).toBe(true) + }) + + it('does not dismiss once connected', () => { + expect(shouldDismissOnClose({hasBeenOpen: true, connected: true})).toBe(false) + }) + + it('does not dismiss a panel that was never opened', () => { + expect(shouldDismissOnClose({hasBeenOpen: false, connected: false})).toBe(false) + }) +}) + +describe('tryButtonLabel', () => { + it('shows the connected label regardless of a stale pending intent', () => { + expect(tryButtonLabel({connected: true, pending: true})).toBe('Open agent panel') + }) + + it('shows a working state for a pre-mount click', () => { + expect(tryButtonLabel({connected: false, pending: true})).toBe('Opening…') + }) + + it('shows the default call to action otherwise', () => { + expect(tryButtonLabel({connected: false, pending: false})).toBe('Try it live') + }) +}) diff --git a/packages/embed/src/mount-impl.tsx b/packages/embed/src/mount-impl.tsx index e73769ea9..5867ec045 100644 --- a/packages/embed/src/mount-impl.tsx +++ b/packages/embed/src/mount-impl.tsx @@ -33,7 +33,15 @@ declare global { } } -type BootResult = {dispose: () => void; rebind?: (apiBase: string) => void} +type BootResult = {dispose: () => void; rebind?: (apiBase: string) => void; interactive: Promise} + +function createInteractiveSignal(): {promise: Promise; notify: () => void} { + let notify = () => {} + const promise = new Promise((resolve) => { + notify = resolve + }) + return {promise, notify} +} function metaContent(name: string): string { return document.querySelector(`meta[name="${name}"]`)?.content ?? '' @@ -97,6 +105,7 @@ function bootNormal(config: BootNormalConfig): BootResult { const restore: {apply: (href: string) => void} = {apply: () => {}} const storage = makeNavigationStorage(rpc, (href) => restore.apply(href)) const hostRouter = window.__TSR_ROUTER__ + const interactive = createInteractiveSignal() const router = createConcivRouter({ rpc, history: createWebStorageHistory({storage}), @@ -109,6 +118,7 @@ function bootNormal(config: BootNormalConfig): BootResult { grabProvider: config.grabProvider, apiBase, connectionGeneration, + notifyInteractive: interactive.notify, }) window.__TSR_ROUTER__ = hostRouter const driver = makeDomPageDriver({tools: mountedClientTools(router), effects: mountedClientEffects(router)}) @@ -155,7 +165,7 @@ function bootNormal(config: BootNormalConfig): BootResult { reachabilityRoot.dispose, closeConnection, ] - return {dispose: () => runDisposers(disposers), rebind} + return {dispose: () => runDisposers(disposers), rebind, interactive: interactive.promise} } type BootConnectConfig = { @@ -185,6 +195,7 @@ function bootConnect(config: BootConnectConfig): BootResult { }).dispose } const hostRouter = window.__TSR_ROUTER__ + const interactive = createInteractiveSignal() const router = createConcivRouter({ rpc: deferred.rpc, history: createMemoryHistory({initialEntries: [connectPath(config.settings)]}), @@ -197,6 +208,7 @@ function bootConnect(config: BootConnectConfig): BootResult { disconnect: makeDisconnect(() => boundApiBase), grabProvider: config.grabProvider, apiBase, + notifyInteractive: interactive.notify, }) window.__TSR_ROUTER__ = hostRouter const driver = makeDomPageDriver({tools: mountedClientTools(router), effects: mountedClientEffects(router)}) @@ -214,7 +226,7 @@ function bootConnect(config: BootConnectConfig): BootResult { reachabilityRoot.dispose, deferred.close, ] - return {dispose: () => runDisposers(disposers)} + return {dispose: () => runDisposers(disposers), interactive: interactive.promise} } async function boot(root: ShadowRoot, init: ConcivInit): Promise { @@ -252,6 +264,7 @@ export function mountImpl( } disposeBoot = result.dispose rebindBoot = result.rebind + return result.interactive }) const rebind = async (apiBase: string): Promise => { await ready diff --git a/packages/embed/tests/e2e/mount-ready.it.test.ts b/packages/embed/tests/e2e/mount-ready.it.test.ts new file mode 100644 index 000000000..0304a1644 --- /dev/null +++ b/packages/embed/tests/e2e/mount-ready.it.test.ts @@ -0,0 +1,36 @@ +import {expect, test, type Page} from '@playwright/test' +import {bootEmbedKit, type EmbedKit} from '../helpers/boot.js' +import {handleHostPage, serveHost} from '../helpers/host.js' +import {chatBox} from './helpers/chat.js' + +let kit: EmbedKit +let host: {base: string; close: () => Promise} + +test.beforeAll(async () => { + kit = await bootEmbedKit({text: 'ready contract'}) + host = await serveHost(() => handleHostPage()) +}) + +test.afterAll(async () => { + await host.close() + await kit.cleanup() +}) + +async function openPage(page: Page): Promise { + await page.goto(host.base, {waitUntil: 'domcontentloaded'}) + return page +} + +test.describe('mount() readiness contract', () => { + test('an open dispatched right after mount() resolves opens the panel, never dropped', async ({page}) => { + await openPage(page) + await page.evaluate(async (base) => { + const el = document.createElement('div') + document.body.appendChild(el) + const handle = window.ConcivHandle.makeHandle(base) + await handle.mount(el) + window.dispatchEvent(new Event('conciv:open-panel')) + }, kit.base) + await expect(chatBox(page)).toBeVisible({timeout: 10_000}) + }) +}) From 7df479456470661dd1a0e18821ec0b888ef08d81 Mon Sep 17 00:00:00 2001 From: Omri Katz <9701896+omridevk@users.noreply.github.com> Date: Sun, 16 Aug 2026 00:21:56 +0300 Subject: [PATCH 2/7] fix(site,embed,protocol): port TanStack Devtools event-bus protocol to close the click-race class MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the three review findings on the try-it-live race fix (stale React snapshot in open(), embed-internals leak in mount-live-widget.ts, unhandled mount-failure leaving the button stuck), then ports the queue-until-connected handshake protocol from TanStack Devtools' event-bus-client (packages/protocol/src/event-bus.ts) so the whole class of "sender fires before receiver is listening" races is closed structurally rather than patched per-event. - createEventBusClient/createEventBusHost: sender queues emits until the receiver acks a connect handshake (bounded retries, then an explicit failed state that drops the queue). - apps/conciv's root route is now the panel-commands host, acking once its open/close/toggle listeners are live (the same point that already resolved mount()'s promise). - embed's createConciv().open()/close()/toggle() and the site's TryLiveButton both emit through bus clients on that channel instead of bare window.dispatchEvent; TryLiveButton's hand-rolled pendingOpen/widget-mounted machinery is gone, replaced by the bus client's own state. - Wire event names are unchanged, so existing raw dispatchers (the iOS bridge, embed's own IT/unit tests) keep working exactly as before — the bus is additive reliability for callers that opt in. - mountConciv() now returns the mount() promise instead of void. Co-Authored-By: Claude Fable 5 --- .changeset/try-live-panel-ready-contract.md | 5 + apps/conciv/src/routes/__root.tsx | 31 ++- .../components/landing/try-live-button.tsx | 79 ++----- apps/site/src/lib/mount-live-widget.ts | 17 +- packages/embed/src/mount.ts | 42 +++- packages/embed/tests/fixtures/global-entry.ts | 2 +- packages/extension-compiler/src/extensions.ts | 2 +- .../extensions/tanstack/test/host/main.tsx | 2 +- packages/plugin/src/nextjs-widget.ts | 2 +- packages/protocol/package.json | 4 + packages/protocol/src/event-bus.ts | 223 ++++++++++++++++++ packages/protocol/test/event-bus.test.ts | 117 +++++++++ packages/protocol/tsdown.config.ts | 1 + 13 files changed, 439 insertions(+), 88 deletions(-) create mode 100644 packages/protocol/src/event-bus.ts create mode 100644 packages/protocol/test/event-bus.test.ts diff --git a/.changeset/try-live-panel-ready-contract.md b/.changeset/try-live-panel-ready-contract.md index 73bb42f43..326d6d0ae 100644 --- a/.changeset/try-live-panel-ready-contract.md +++ b/.changeset/try-live-panel-ready-contract.md @@ -1,5 +1,10 @@ --- '@conciv/embed': patch +'@conciv/protocol': patch --- `mount(el)` now resolves only once the widget can actually honor `open()`/`conciv:open-panel` — previously the returned promise settled as soon as the app's boot sequence finished computing, which raced ahead of the root route's `onMount` (where the open-panel listener registers). Any embedder that opens the panel immediately after `mount()` resolves — including a landing page's "Try it live" button that dispatches an early click before the widget bundle finishes loading — no longer has that open silently dropped. + +`mountConciv(extensions)` now returns the underlying `mount()` promise instead of `void`, so a caller can `await` it (or observe a rejection) instead of the widget's readiness being unobservable outside `createConciv`. Existing fire-and-forget call sites keep working unchanged; they just ignore the returned promise. + +New `@conciv/protocol/event-bus` export: `createEventBusClient`/`createEventBusHost`, a queue-until-connected event handshake (ported from TanStack Devtools' event-bus-client protocol) that eliminates the whole "sender fires before receiver is listening" class of races, not just the one above. `createConciv().open()`/`close()`/`toggle()` now emit through a bus client on the `conciv:panel-commands` channel instead of a bare `window.dispatchEvent`; the widget's root route answers as the bus host once its command listeners are registered. The wire event names (`conciv:open-panel`, `conciv:close-panel`, `conciv:toggle-panel`, `conciv:connection-changed`, `conciv:panel-toggled`) are unchanged, so existing raw dispatchers (the iOS bridge, tests) keep working exactly as before — the bus is additive reliability for callers that opt into it, not a breaking wire-protocol change. diff --git a/apps/conciv/src/routes/__root.tsx b/apps/conciv/src/routes/__root.tsx index 0d15ffa90..56205d34b 100644 --- a/apps/conciv/src/routes/__root.tsx +++ b/apps/conciv/src/routes/__root.tsx @@ -14,6 +14,16 @@ import {showToast} from '@conciv/page' import {createHotkey} from '@tanstack/solid-hotkeys' import {Show, createEffect, createSignal, onCleanup, onMount} from 'solid-js' import {makeEventListener} from '@solid-primitives/event-listener' +import { + CLOSE_PANEL_EVENT, + CONNECTION_CHANGED_EVENT, + createEventBusHost, + OPEN_PANEL_EVENT, + PANEL_COMMAND_CHANNEL, + PANEL_TOGGLED_EVENT, + TOGGLE_PANEL_EVENT, + type PanelCommandEventMap, +} from '@conciv/protocol/event-bus' import type {ConcivRouterContext} from '../router.js' import { AppContext, @@ -119,7 +129,7 @@ function RootComponent() { createEffect(() => { const isConnected = app.connected() - window.dispatchEvent(new CustomEvent('conciv:connection-changed', {detail: {connected: isConnected}})) + window.dispatchEvent(new CustomEvent(CONNECTION_CHANGED_EVENT, {detail: {connected: isConnected}})) }) const reachability = makeEngineReachability() @@ -197,7 +207,7 @@ function RootChrome(props: { const reportPanelState = () => { const open = panelOpen() window.dispatchEvent( - new CustomEvent('conciv:panel-toggled', { + new CustomEvent(PANEL_TOGGLED_EVENT, { detail: {open, connected: connected(), mascotRect: open ? null : mascotRect()}, }), ) @@ -262,19 +272,20 @@ function RootChrome(props: { onCleanup(() => cancelAnimationFrame(frame)) }) + const panelCommandHost = createEventBusHost({channel: PANEL_COMMAND_CHANNEL}) + onMount(() => { if (settings.defaultOpen && closedMatch()) void openPanel() - const openFromHost = () => void openPanel() - const closeFromHost = () => { - if (panelOpen()) closePanel() - } - const toggleFromHost = () => togglePanel() makeEventListener(window, 'resize', reportPanelState) - makeEventListener(window, 'conciv:open-panel', openFromHost) - makeEventListener(window, 'conciv:close-panel', closeFromHost) - makeEventListener(window, 'conciv:toggle-panel', toggleFromHost) + panelCommandHost.on(OPEN_PANEL_EVENT, () => void openPanel()) + panelCommandHost.on(CLOSE_PANEL_EVENT, () => { + if (panelOpen()) closePanel() + }) + panelCommandHost.on(TOGGLE_PANEL_EVENT, () => togglePanel()) + panelCommandHost.ready() notifyInteractive() }) + onCleanup(() => panelCommandHost.dispose()) const onKeyDown = (event: KeyboardEvent) => { if (event.key !== 'Escape') return diff --git a/apps/site/src/components/landing/try-live-button.tsx b/apps/site/src/components/landing/try-live-button.tsx index 51c35a0a3..284581c0a 100644 --- a/apps/site/src/components/landing/try-live-button.tsx +++ b/apps/site/src/components/landing/try-live-button.tsx @@ -1,48 +1,31 @@ import {useSyncExternalStore} from 'react' import {Button} from '@/components/ui/button' +import { + CONNECTION_CHANGED_EVENT, + createEventBusClient, + OPEN_PANEL_EVENT, + PANEL_COMMAND_CHANNEL, + type EventBusClientState, + type PanelCommandEventMap, + type WidgetConnectionChangedDetail, +} from '@conciv/protocol/event-bus' import {tryButtonLabel} from '@/lib/try-state' declare global { interface WindowEventMap { - 'conciv:connection-changed': CustomEvent<{connected: boolean}> + [CONNECTION_CHANGED_EVENT]: CustomEvent } } let connected = false -let mounted = false -let pendingOpen = false -const pendingListeners = new Set<() => void>() - -function notifyPending(): void { - for (const listener of pendingListeners) listener() -} function subscribeConnection(onChange: () => void): () => void { - const handler = (event: WindowEventMap['conciv:connection-changed']) => { + const handler = (event: WindowEventMap[typeof CONNECTION_CHANGED_EVENT]) => { connected = event.detail.connected onChange() } - window.addEventListener('conciv:connection-changed', handler) - return () => window.removeEventListener('conciv:connection-changed', handler) -} - -function subscribeMounted(onChange: () => void): () => void { - const handler = () => { - mounted = true - if (pendingOpen) { - pendingOpen = false - window.dispatchEvent(new Event('conciv:open-panel')) - notifyPending() - } - onChange() - } - window.addEventListener('conciv:widget-mounted', handler) - return () => window.removeEventListener('conciv:widget-mounted', handler) -} - -function subscribePending(onChange: () => void): () => void { - pendingListeners.add(onChange) - return () => pendingListeners.delete(onChange) + window.addEventListener(CONNECTION_CHANGED_EVENT, handler) + return () => window.removeEventListener(CONNECTION_CHANGED_EVENT, handler) } function useConcivConnected(): boolean { @@ -53,39 +36,25 @@ function useConcivConnected(): boolean { ) } -function useWidgetMounted(): boolean { - return useSyncExternalStore( - subscribeMounted, - () => mounted, - () => false, - ) -} +const panelCommands = createEventBusClient({ + channel: PANEL_COMMAND_CHANNEL, + reconnectEveryMs: 500, + maxRetries: 60, +}) -function usePendingOpen(): boolean { - return useSyncExternalStore( - subscribePending, - () => pendingOpen, - () => false, - ) +function useBusState(): EventBusClientState { + return useSyncExternalStore(panelCommands.subscribe, panelCommands.getState, () => 'idle') } export function TryLiveButton() { const isConnected = useConcivConnected() - const isMounted = useWidgetMounted() - const isPending = usePendingOpen() - const open = () => { - if (isMounted) { - window.dispatchEvent(new Event('conciv:open-panel')) - return - } - pendingOpen = true - notifyPending() - } + const busState = useBusState() + const open = () => panelCommands.emit(OPEN_PANEL_EVENT, undefined) return (
-
) diff --git a/apps/site/src/lib/mount-live-widget.ts b/apps/site/src/lib/mount-live-widget.ts index 0227f307d..169302c48 100644 --- a/apps/site/src/lib/mount-live-widget.ts +++ b/apps/site/src/lib/mount-live-widget.ts @@ -1,6 +1,13 @@ +import {PANEL_TOGGLED_EVENT, type WidgetPanelToggledDetail} from '@conciv/protocol/event-bus' import {dismissTry, getTrySession} from './try-session.functions' import {shouldAutoOpen, shouldDismissOnClose} from './try-state' +declare global { + interface WindowEventMap { + [PANEL_TOGGLED_EVENT]: CustomEvent + } +} + function ensureWidgetMeta(defaultOpen: boolean): void { if (document.querySelector('meta[name="pw-widget"]')) return const meta = document.createElement('meta') @@ -23,15 +30,11 @@ export async function mountLiveWidget(opts: {widgetOpen: boolean; tryParam: bool import('@conciv/extension-try-it/client'), ]) if (document.querySelector('[data-conciv-root]')) return - const root = document.createElement('div') - root.setAttribute('data-conciv-script-root', '') - document.body.appendChild(root) - await embed.createConciv({extensions: [terminal.default, tryItModule.tryIt({token})]}).mount(root) - window.dispatchEvent(new Event('conciv:widget-mounted')) + await embed.mountConciv([terminal.default, tryItModule.tryIt({token})]) let hasBeenOpen = false - window.addEventListener('conciv:panel-toggled', (event) => { - const detail = (event as CustomEvent<{open: boolean; connected: boolean}>).detail + window.addEventListener(PANEL_TOGGLED_EVENT, (event) => { + const detail = event.detail if (!detail) return if (detail.open) { hasBeenOpen = true diff --git a/packages/embed/src/mount.ts b/packages/embed/src/mount.ts index 9cabd9974..df4744007 100644 --- a/packages/embed/src/mount.ts +++ b/packages/embed/src/mount.ts @@ -1,6 +1,7 @@ import type {AnyExtension} from '@conciv/extension' import type {GrabProvider} from '@conciv/grab' import type {ConcivSettingsInit} from '@conciv/protocol/config-types' +import type {EventBusClient, PanelCommandEventMap} from '@conciv/protocol/event-bus' export type {ConcivSettingsInit} from '@conciv/protocol/config-types' @@ -24,9 +25,11 @@ export type ConcivHandle = { type MountState = 'unmounted' | 'mounting' | 'mounted' -function dispatch(name: string, detail?: Record): void { - if (typeof window === 'undefined') return - window.dispatchEvent(new CustomEvent(name, {detail})) +type PanelCommandName = keyof PanelCommandEventMap & string + +type PanelCommandsBus = { + client: EventBusClient + events: {open: PanelCommandName; close: PanelCommandName; toggle: PanelCommandName} } export function createConciv(init: ConcivInit = {}): ConcivHandle { @@ -34,6 +37,20 @@ export function createConciv(init: ConcivInit = {}): ConcivHandle { let abort: AbortController | undefined let teardown: (() => void) | undefined let rebindImpl: ((apiBase: string) => Promise) | undefined + let panelCommandsPromise: Promise | null = null + + function panelCommands(): Promise { + if (panelCommandsPromise) return panelCommandsPromise + panelCommandsPromise = import('@conciv/protocol/event-bus').then((eventBus) => ({ + client: eventBus.createEventBusClient({ + channel: eventBus.PANEL_COMMAND_CHANNEL, + reconnectEveryMs: 500, + maxRetries: 60, + }), + events: {open: eventBus.OPEN_PANEL_EVENT, close: eventBus.CLOSE_PANEL_EVENT, toggle: eventBus.TOGGLE_PANEL_EVENT}, + })) + return panelCommandsPromise + } async function mount(el: HTMLElement): Promise { if (typeof document === 'undefined') return @@ -70,15 +87,18 @@ export function createConciv(init: ConcivInit = {}): ConcivHandle { } function open(): void { - dispatch('conciv:open-panel') + if (typeof window === 'undefined') return + void panelCommands().then((bus) => bus.client.emit(bus.events.open, undefined)) } function close(): void { - dispatch('conciv:close-panel') + if (typeof window === 'undefined') return + void panelCommands().then((bus) => bus.client.emit(bus.events.close, undefined)) } function toggle(): void { - dispatch('conciv:toggle-panel') + if (typeof window === 'undefined') return + void panelCommands().then((bus) => bus.client.emit(bus.events.toggle, undefined)) } async function rebind(apiBase: string): Promise { @@ -89,13 +109,11 @@ export function createConciv(init: ConcivInit = {}): ConcivHandle { return {mount, unmount, open, close, toggle, rebind} } -export function mountConciv(extensions: AnyExtension[]): void { - if (typeof document === 'undefined') return - if (document.querySelector('[data-conciv-script-root]')) return +export function mountConciv(extensions: AnyExtension[]): Promise { + if (typeof document === 'undefined') return Promise.resolve() + if (document.querySelector('[data-conciv-script-root]')) return Promise.resolve() const el = document.createElement('div') el.setAttribute('data-conciv-script-root', '') document.body.appendChild(el) - void createConciv({extensions}) - .mount(el) - .catch(() => undefined) + return createConciv({extensions}).mount(el) } diff --git a/packages/embed/tests/fixtures/global-entry.ts b/packages/embed/tests/fixtures/global-entry.ts index f8cbb6d2b..444020d94 100644 --- a/packages/embed/tests/fixtures/global-entry.ts +++ b/packages/embed/tests/fixtures/global-entry.ts @@ -2,4 +2,4 @@ import terminal from '@conciv/extension-terminal/client' import recorder from '@conciv/extension-recorder/client' import {mountConciv} from '../../src/mount.js' -mountConciv([terminal, recorder]) +void mountConciv([terminal, recorder]) diff --git a/packages/extension-compiler/src/extensions.ts b/packages/extension-compiler/src/extensions.ts index 027334cd1..d17b7b4cf 100644 --- a/packages/extension-compiler/src/extensions.ts +++ b/packages/extension-compiler/src/extensions.ts @@ -42,7 +42,7 @@ export function extensionsModuleSource( `const builtinEntries = [${builtinNames.map((n, i) => `{extension: ${n}, source: 'builtin:${i}'}`).join(', ')}]`, `const picked = dedupeExtensions([...builtinEntries, ...folderEntries])`, `for (const d of picked.dropped) console.warn('conciv extension dropped:', d.source, d.reason)`, - `mountConciv(picked.extensions)`, + `void mountConciv(picked.extensions)`, '', ].join('\n') } diff --git a/packages/extensions/tanstack/test/host/main.tsx b/packages/extensions/tanstack/test/host/main.tsx index 89b1a93b0..3ada8131b 100644 --- a/packages/extensions/tanstack/test/host/main.tsx +++ b/packages/extensions/tanstack/test/host/main.tsx @@ -152,5 +152,5 @@ function App() { const rootElement = document.getElementById('root') if (rootElement) { createRoot(rootElement).render() - mountConciv([tanstackExtension, connectProbe]) + void mountConciv([tanstackExtension, connectProbe]) } diff --git a/packages/plugin/src/nextjs-widget.ts b/packages/plugin/src/nextjs-widget.ts index 2bea6a608..92b4b188b 100644 --- a/packages/plugin/src/nextjs-widget.ts +++ b/packages/plugin/src/nextjs-widget.ts @@ -11,7 +11,7 @@ async function startWidget(): Promise { ]) const picked = dedupeExtensions(entries) for (const drop of picked.dropped) console.warn('conciv extension dropped:', drop.source, drop.reason) - mountConciv(picked.extensions) + await mountConciv(picked.extensions) } if (typeof window !== 'undefined' && port && process.env.NODE_ENV !== 'production') { diff --git a/packages/protocol/package.json b/packages/protocol/package.json index c6bb52d79..41b3954e9 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -92,6 +92,10 @@ "./state-types": { "types": "./dist/state-types.d.ts", "import": "./dist/state-types.js" + }, + "./event-bus": { + "types": "./dist/event-bus.d.ts", + "import": "./dist/event-bus.js" } }, "publishConfig": { diff --git a/packages/protocol/src/event-bus.ts b/packages/protocol/src/event-bus.ts new file mode 100644 index 000000000..35a851195 --- /dev/null +++ b/packages/protocol/src/event-bus.ts @@ -0,0 +1,223 @@ +declare const window: EventTarget | undefined + +declare global { + var __CONCIV_EVENT_BUS_TARGET__: EventTarget | undefined +} + +function sharedEventTarget(): EventTarget { + if (typeof window !== 'undefined') return window + if (globalThis.__CONCIV_EVENT_BUS_TARGET__ === undefined) globalThis.__CONCIV_EVENT_BUS_TARGET__ = new EventTarget() + return globalThis.__CONCIV_EVENT_BUS_TARGET__ +} + +export type EventBusTarget = () => EventTarget + +export type EventBusScheduler = { + setInterval: (callback: () => void, ms: number) => unknown + clearInterval: (id: unknown) => void +} + +function defaultScheduler(): EventBusScheduler { + const timers = new Map>() + let nextId = 0 + return { + setInterval: (callback, ms) => { + const id = nextId + nextId += 1 + timers.set(id, setInterval(callback, ms)) + return id + }, + clearInterval: (id) => { + if (typeof id !== 'number') return + const timer = timers.get(id) + if (timer !== undefined) clearInterval(timer) + timers.delete(id) + }, + } +} + +export type EventBusClientState = 'idle' | 'connecting' | 'ready' | 'failed' + +export type EventBusClientOptions = { + channel: string + target?: EventBusTarget + scheduler?: EventBusScheduler + reconnectEveryMs?: number + maxRetries?: number +} + +export type EventBusClient> = { + emit: (name: TName, payload: TEventMap[TName]) => void + getState: () => EventBusClientState + subscribe: (listener: () => void) => () => void + dispose: () => void +} + +type QueuedEvent = {name: string; payload: unknown} + +export function createEventBusClient>( + options: EventBusClientOptions, +): EventBusClient { + const target = options.target ?? sharedEventTarget + const scheduler = options.scheduler ?? defaultScheduler() + const reconnectEveryMs = options.reconnectEveryMs ?? 300 + const maxRetries = options.maxRetries ?? 5 + const connectEventName = `${options.channel}:connect` + const connectSuccessEventName = `${options.channel}:connect-success` + + let state: EventBusClientState = 'idle' + let queue: QueuedEvent[] = [] + let retryCount = 0 + let intervalId: unknown = null + const listeners = new Set<() => void>() + + function notify(): void { + for (const listener of listeners) listener() + } + + function dispatch(name: string, payload: unknown): void { + target().dispatchEvent(new CustomEvent(name, {detail: payload})) + } + + function flushQueue(): void { + const pending = queue + queue = [] + for (const event of pending) dispatch(event.name, event.payload) + } + + function stopConnectLoop(): void { + if (intervalId === null) return + scheduler.clearInterval(intervalId) + intervalId = null + } + + function onConnectSuccess(): void { + target().removeEventListener(connectSuccessEventName, onConnectSuccess) + stopConnectLoop() + state = 'ready' + flushQueue() + notify() + } + + function attemptConnect(): void { + if (retryCount >= maxRetries) { + target().removeEventListener(connectSuccessEventName, onConnectSuccess) + stopConnectLoop() + state = 'failed' + queue = [] + notify() + return + } + retryCount += 1 + target().dispatchEvent(new CustomEvent(connectEventName)) + } + + function startHandshake(): void { + state = 'connecting' + target().addEventListener(connectSuccessEventName, onConnectSuccess) + attemptConnect() + if (state === 'connecting') intervalId = scheduler.setInterval(attemptConnect, reconnectEveryMs) + notify() + } + + function emit(name: TName, payload: TEventMap[TName]): void { + if (state === 'ready') { + dispatch(name, payload) + return + } + if (state === 'failed') return + queue.push({name, payload}) + if (state === 'idle') startHandshake() + } + + function getState(): EventBusClientState { + return state + } + + function subscribe(listener: () => void): () => void { + listeners.add(listener) + return () => listeners.delete(listener) + } + + function dispose(): void { + stopConnectLoop() + target().removeEventListener(connectSuccessEventName, onConnectSuccess) + listeners.clear() + queue = [] + } + + return {emit, getState, subscribe, dispose} +} + +export type EventBusHostOptions = { + channel: string + target?: EventBusTarget +} + +export type EventBusHost> = { + on: (name: TName, handler: (payload: TEventMap[TName]) => void) => void + ready: () => void + dispose: () => void +} + +type RegisteredListener = {name: string; listener: (event: Event) => void} + +export function createEventBusHost>( + options: EventBusHostOptions, +): EventBusHost { + const target = options.target ?? sharedEventTarget + const connectEventName = `${options.channel}:connect` + const connectSuccessEventName = `${options.channel}:connect-success` + const registered: RegisteredListener[] = [] + let connectListener: (() => void) | null = null + + function on(name: TName, handler: (payload: TEventMap[TName]) => void): void { + const listener = (event: Event) => handler((event as CustomEvent).detail) + registered.push({name, listener}) + target().addEventListener(name, listener) + } + + function ready(): void { + if (connectListener) return + connectListener = () => target().dispatchEvent(new CustomEvent(connectSuccessEventName)) + target().addEventListener(connectEventName, connectListener) + } + + function dispose(): void { + for (const entry of registered) target().removeEventListener(entry.name, entry.listener) + registered.length = 0 + if (connectListener) { + target().removeEventListener(connectEventName, connectListener) + connectListener = null + } + } + + return {on, ready, dispose} +} + +export const PANEL_COMMAND_CHANNEL = 'conciv:panel-commands' +export const OPEN_PANEL_EVENT = 'conciv:open-panel' +export const CLOSE_PANEL_EVENT = 'conciv:close-panel' +export const TOGGLE_PANEL_EVENT = 'conciv:toggle-panel' + +export type PanelCommandEventMap = { + [OPEN_PANEL_EVENT]: undefined + [CLOSE_PANEL_EVENT]: undefined + [TOGGLE_PANEL_EVENT]: undefined +} + +export const CONNECTION_CHANGED_EVENT = 'conciv:connection-changed' +export const PANEL_TOGGLED_EVENT = 'conciv:panel-toggled' + +export type WidgetConnectionChangedDetail = {connected: boolean} + +export type WidgetPanelToggledDetail = { + open: boolean + connected: boolean + mascotRect: {x: number; y: number; width: number; height: number} | null +} + +export type WidgetStatusEventMap = { + [CONNECTION_CHANGED_EVENT]: WidgetConnectionChangedDetail + [PANEL_TOGGLED_EVENT]: WidgetPanelToggledDetail +} diff --git a/packages/protocol/test/event-bus.test.ts b/packages/protocol/test/event-bus.test.ts new file mode 100644 index 000000000..028a64b91 --- /dev/null +++ b/packages/protocol/test/event-bus.test.ts @@ -0,0 +1,117 @@ +import {describe, expect, it} from 'vitest' +import {createEventBusClient, createEventBusHost, type EventBusScheduler} from '../src/event-bus.js' + +type CommandMap = { + open: undefined + close: undefined +} + +function createManualScheduler(): {scheduler: EventBusScheduler; tick: () => void; intervalCount: () => number} { + const callbacks = new Map void>() + let nextId = 0 + let cleared = 0 + return { + scheduler: { + setInterval: (callback) => { + const id = nextId + nextId += 1 + callbacks.set(id, callback) + return id + }, + clearInterval: (id) => { + if (typeof id !== 'number') return + callbacks.delete(id) + cleared += 1 + }, + }, + tick: () => { + for (const callback of callbacks.values()) callback() + }, + intervalCount: () => callbacks.size - cleared, + } +} + +describe('createEventBusClient / createEventBusHost', () => { + it('queues emits made before the handshake completes and flushes them in order once acked', () => { + const target = new EventTarget() + const {scheduler, tick} = createManualScheduler() + const received: unknown[] = [] + const host = createEventBusHost({channel: 'test-channel', target: () => target}) + host.on('open', (payload) => received.push(['open', payload])) + host.on('close', (payload) => received.push(['close', payload])) + + const client = createEventBusClient({channel: 'test-channel', target: () => target, scheduler}) + client.emit('open', undefined) + client.emit('close', undefined) + expect(received).toEqual([]) + expect(client.getState()).toBe('connecting') + + host.ready() + expect(received).toEqual([]) + + tick() + expect(received).toEqual([ + ['open', null], + ['close', null], + ]) + expect(client.getState()).toBe('ready') + }) + + it('stops retrying once the host acks the connection', () => { + const target = new EventTarget() + const {scheduler, tick, intervalCount} = createManualScheduler() + const host = createEventBusHost({channel: 'test-channel', target: () => target}) + host.on('open', () => {}) + host.ready() + + const client = createEventBusClient({channel: 'test-channel', target: () => target, scheduler}) + client.emit('open', undefined) + expect(client.getState()).toBe('ready') + expect(intervalCount()).toBe(0) + + tick() + expect(client.getState()).toBe('ready') + }) + + it('gives up after the bounded retry count and drops the queue', () => { + const target = new EventTarget() + const {scheduler, tick} = createManualScheduler() + const client = createEventBusClient({ + channel: 'test-channel', + target: () => target, + scheduler, + maxRetries: 2, + }) + + client.emit('open', undefined) + expect(client.getState()).toBe('connecting') + + tick() + expect(client.getState()).toBe('connecting') + + tick() + expect(client.getState()).toBe('failed') + + const received: unknown[] = [] + const host = createEventBusHost({channel: 'test-channel', target: () => target}) + host.on('open', (payload) => received.push(payload)) + host.ready() + expect(received).toEqual([]) + }) + + it('passes an emit straight through once the client is already connected', () => { + const target = new EventTarget() + const {scheduler} = createManualScheduler() + const received: unknown[] = [] + const host = createEventBusHost({channel: 'test-channel', target: () => target}) + host.on('open', (payload) => received.push(payload)) + host.ready() + + const client = createEventBusClient({channel: 'test-channel', target: () => target, scheduler}) + client.emit('open', undefined) + expect(client.getState()).toBe('ready') + + client.emit('open', undefined) + expect(received).toEqual([null, null]) + }) +}) diff --git a/packages/protocol/tsdown.config.ts b/packages/protocol/tsdown.config.ts index 64ab878f3..4758852ad 100644 --- a/packages/protocol/tsdown.config.ts +++ b/packages/protocol/tsdown.config.ts @@ -20,6 +20,7 @@ export default defineConfig({ 'src/tool-view-types.ts', 'src/connect-ports.ts', 'src/state-types.ts', + 'src/event-bus.ts', ], format: 'esm', noExternal: [/^@tanstack\/ai(\/|$)/], From b59e70afbbe37feeac30d4e7a1050151e90ee861 Mon Sep 17 00:00:00 2001 From: Omri Katz <9701896+omridevk@users.noreply.github.com> Date: Sun, 16 Aug 2026 00:34:27 +0300 Subject: [PATCH 3/7] fix(protocol): event bus emit-after-failure restarts handshake; simplify scheduler emit() called while the client is in 'failed' state now resets retryCount and restarts the handshake instead of silently dropping the event, so a client that exhausted its retry budget can recover on new user intent without a page reload. Also drops the Map+counter wrapper in defaultScheduler: setInterval's own return value is already an opaque handle, so it's passed straight through to clearInterval. Co-Authored-By: Claude Fable 5 --- packages/protocol/src/event-bus.ts | 26 ++++++++++----------- packages/protocol/test/event-bus.test.ts | 29 ++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 13 deletions(-) diff --git a/packages/protocol/src/event-bus.ts b/packages/protocol/src/event-bus.ts index 35a851195..dca08aacb 100644 --- a/packages/protocol/src/event-bus.ts +++ b/packages/protocol/src/event-bus.ts @@ -17,21 +17,16 @@ export type EventBusScheduler = { clearInterval: (id: unknown) => void } +function isTimerHandle(id: unknown): id is Parameters[0] { + return id !== null +} + function defaultScheduler(): EventBusScheduler { - const timers = new Map>() - let nextId = 0 return { - setInterval: (callback, ms) => { - const id = nextId - nextId += 1 - timers.set(id, setInterval(callback, ms)) - return id - }, + setInterval: (callback, ms) => setInterval(callback, ms), clearInterval: (id) => { - if (typeof id !== 'number') return - const timer = timers.get(id) - if (timer !== undefined) clearInterval(timer) - timers.delete(id) + if (!isTimerHandle(id)) return + clearInterval(id) }, } } @@ -125,7 +120,12 @@ export function createEventBusClient>( dispatch(name, payload) return } - if (state === 'failed') return + if (state === 'failed') { + retryCount = 0 + queue.push({name, payload}) + startHandshake() + return + } queue.push({name, payload}) if (state === 'idle') startHandshake() } diff --git a/packages/protocol/test/event-bus.test.ts b/packages/protocol/test/event-bus.test.ts index 028a64b91..3e9ca3ef8 100644 --- a/packages/protocol/test/event-bus.test.ts +++ b/packages/protocol/test/event-bus.test.ts @@ -99,6 +99,35 @@ describe('createEventBusClient / createEventBusHost', () => { expect(received).toEqual([]) }) + it('restarts the handshake with a fresh retry budget when emit is called again after failure', () => { + const target = new EventTarget() + const {scheduler, tick} = createManualScheduler() + const client = createEventBusClient({ + channel: 'test-channel', + target: () => target, + scheduler, + maxRetries: 2, + }) + + client.emit('open', undefined) + tick() + tick() + expect(client.getState()).toBe('failed') + + client.emit('close', undefined) + expect(client.getState()).toBe('connecting') + + const received: unknown[] = [] + const host = createEventBusHost({channel: 'test-channel', target: () => target}) + host.on('close', (payload) => received.push(payload)) + host.ready() + expect(received).toEqual([]) + + tick() + expect(received).toEqual([null]) + expect(client.getState()).toBe('ready') + }) + it('passes an emit straight through once the client is already connected', () => { const target = new EventTarget() const {scheduler} = createManualScheduler() From a62cd0e6cbbea8b28d6e8a1a71c97dbd018a1bae Mon Sep 17 00:00:00 2001 From: Omri Katz <9701896+omridevk@users.noreply.github.com> Date: Sun, 16 Aug 2026 01:03:10 +0300 Subject: [PATCH 4/7] fix(embed): dispose bus client on unmount, guard panel command import failure, clean up script root on mount failure Prevents a pre-unmount panel command from leaving the bus client's retry interval running forever, stops a rejected dynamic import from poisoning every later open/close/toggle call, and removes the mounted script-root marker if mount() rejects so mountConciv() can retry instead of early-returning forever. Co-Authored-By: Claude Fable 5 --- packages/embed/src/mount.ts | 30 +++++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/packages/embed/src/mount.ts b/packages/embed/src/mount.ts index df4744007..565f5ad5b 100644 --- a/packages/embed/src/mount.ts +++ b/packages/embed/src/mount.ts @@ -84,21 +84,32 @@ export function createConciv(init: ConcivInit = {}): ConcivHandle { teardown = undefined rebindImpl = undefined state = 'unmounted' + if (panelCommandsPromise) { + void panelCommandsPromise.then((bus) => bus.client.dispose()).catch(() => {}) + panelCommandsPromise = null + } } - function open(): void { + function emitPanelCommand(pick: (events: PanelCommandsBus['events']) => PanelCommandName): void { if (typeof window === 'undefined') return - void panelCommands().then((bus) => bus.client.emit(bus.events.open, undefined)) + void panelCommands() + .then((bus) => bus.client.emit(pick(bus.events), undefined)) + .catch((error: unknown) => { + panelCommandsPromise = null + console.error('[conciv] panel command failed', error) + }) + } + + function open(): void { + emitPanelCommand((events) => events.open) } function close(): void { - if (typeof window === 'undefined') return - void panelCommands().then((bus) => bus.client.emit(bus.events.close, undefined)) + emitPanelCommand((events) => events.close) } function toggle(): void { - if (typeof window === 'undefined') return - void panelCommands().then((bus) => bus.client.emit(bus.events.toggle, undefined)) + emitPanelCommand((events) => events.toggle) } async function rebind(apiBase: string): Promise { @@ -115,5 +126,10 @@ export function mountConciv(extensions: AnyExtension[]): Promise { const el = document.createElement('div') el.setAttribute('data-conciv-script-root', '') document.body.appendChild(el) - return createConciv({extensions}).mount(el) + return createConciv({extensions}) + .mount(el) + .catch((error: unknown) => { + el.remove() + throw error + }) } From 8a893cd673f638893d077436fa42fe44169d5707 Mon Sep 17 00:00:00 2001 From: Omri Katz <9701896+omridevk@users.noreply.github.com> Date: Sun, 16 Aug 2026 01:28:04 +0300 Subject: [PATCH 5/7] fix(embed,plugin,extension-compiler): resolve interactive on dispose; guard fire-and-forget mountConciv call sites; fix test scheduler accounting Teardown between boot() resolving and the widget's onMount left the interactive promise pending forever, hanging mount(). Disposal now resolves it. The extension-compiler bootstrap template and the Next.js widget shim both fire-and-forgot mountConciv, which now rejects on startup failure; both call sites catch and log. The manual test scheduler's interval accounting subtracted cumulative clears from live size, going negative after a genuine clear. Co-Authored-By: Claude Fable 5 --- packages/embed/src/mount-impl.tsx | 2 ++ packages/extension-compiler/src/extensions.ts | 2 +- packages/plugin/src/nextjs-widget.ts | 8 ++++++-- packages/protocol/test/event-bus.test.ts | 4 +--- 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/embed/src/mount-impl.tsx b/packages/embed/src/mount-impl.tsx index 5867ec045..ad2fb8498 100644 --- a/packages/embed/src/mount-impl.tsx +++ b/packages/embed/src/mount-impl.tsx @@ -164,6 +164,7 @@ function bootNormal(config: BootNormalConfig): BootResult { driver.dispose, reachabilityRoot.dispose, closeConnection, + interactive.notify, ] return {dispose: () => runDisposers(disposers), rebind, interactive: interactive.promise} } @@ -225,6 +226,7 @@ function bootConnect(config: BootConnectConfig): BootResult { driver.dispose, reachabilityRoot.dispose, deferred.close, + interactive.notify, ] return {dispose: () => runDisposers(disposers), interactive: interactive.promise} } diff --git a/packages/extension-compiler/src/extensions.ts b/packages/extension-compiler/src/extensions.ts index d17b7b4cf..eb61bdd47 100644 --- a/packages/extension-compiler/src/extensions.ts +++ b/packages/extension-compiler/src/extensions.ts @@ -42,7 +42,7 @@ export function extensionsModuleSource( `const builtinEntries = [${builtinNames.map((n, i) => `{extension: ${n}, source: 'builtin:${i}'}`).join(', ')}]`, `const picked = dedupeExtensions([...builtinEntries, ...folderEntries])`, `for (const d of picked.dropped) console.warn('conciv extension dropped:', d.source, d.reason)`, - `void mountConciv(picked.extensions)`, + `mountConciv(picked.extensions).catch((error) => console.error('[conciv] widget failed to start', error))`, '', ].join('\n') } diff --git a/packages/plugin/src/nextjs-widget.ts b/packages/plugin/src/nextjs-widget.ts index 92b4b188b..f8bc25333 100644 --- a/packages/plugin/src/nextjs-widget.ts +++ b/packages/plugin/src/nextjs-widget.ts @@ -16,9 +16,13 @@ async function startWidget(): Promise { if (typeof window !== 'undefined' && port && process.env.NODE_ENV !== 'production') { if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', () => void startWidget(), {once: true}) + document.addEventListener( + 'DOMContentLoaded', + () => startWidget().catch((error) => console.error('[conciv] widget failed to start', error)), + {once: true}, + ) } else { - void startWidget() + startWidget().catch((error) => console.error('[conciv] widget failed to start', error)) } } diff --git a/packages/protocol/test/event-bus.test.ts b/packages/protocol/test/event-bus.test.ts index 3e9ca3ef8..3d7ab508c 100644 --- a/packages/protocol/test/event-bus.test.ts +++ b/packages/protocol/test/event-bus.test.ts @@ -9,7 +9,6 @@ type CommandMap = { function createManualScheduler(): {scheduler: EventBusScheduler; tick: () => void; intervalCount: () => number} { const callbacks = new Map void>() let nextId = 0 - let cleared = 0 return { scheduler: { setInterval: (callback) => { @@ -21,13 +20,12 @@ function createManualScheduler(): {scheduler: EventBusScheduler; tick: () => voi clearInterval: (id) => { if (typeof id !== 'number') return callbacks.delete(id) - cleared += 1 }, }, tick: () => { for (const callback of callbacks.values()) callback() }, - intervalCount: () => callbacks.size - cleared, + intervalCount: () => callbacks.size, } } From 266a0c2f970a48642ab90153d3c0e3cac6e29829 Mon Sep 17 00:00:00 2001 From: Omri Katz <9701896+omridevk@users.noreply.github.com> Date: Sun, 16 Aug 2026 02:01:23 +0300 Subject: [PATCH 6/7] refactor(protocol): faithful port of the TanStack Devtools in-page event bus Rewrites the panel-command event bus as an envelope protocol mirroring TanStack Devtools' EventClient/ClientEventBus: - every emit wraps as {type: ':', payload, pluginId} and rides one fixed bus event, conciv-dispatch-event - fixed global handshake: conciv-connect / conciv-connect-success - createEventBus(start/stop) re-dispatches each envelope as both the specific : event and a global conciv-global event, and answers the connect handshake - client on()/onAll() replace the old host-side on(); onAll filters by pluginId; both return unsubscribers - queue-until-connected, bounded retry, ack flushes in order Panel commands move to pluginId 'panel' with open/close/toggle suffixes, so the wire events are panel:open/panel:close/panel:toggle. The widget root, embed handle, landing button and iOS bridge all speak the bus through a client. Status events stay raw window events. Co-Authored-By: Claude Fable 5 --- .changeset/try-live-panel-ready-contract.md | 7 +- apps/conciv/src/routes/__root.tsx | 31 +-- .../components/landing/try-live-button.tsx | 7 +- packages/embed/src/mount.ts | 30 ++- .../embed/tests/e2e/mount-ready.it.test.ts | 6 +- .../embed/tests/e2e/panel-focus.it.test.ts | 8 +- .../embed/tests/unit/native-bundle.test.ts | 2 +- packages/extensions/ios/package.json | 1 + packages/extensions/ios/src/client.tsx | 15 +- packages/protocol/src/event-bus.ts | 198 +++++++++++------- packages/protocol/test/event-bus.test.ts | 128 ++++++++--- pnpm-lock.yaml | 27 +-- 12 files changed, 305 insertions(+), 155 deletions(-) diff --git a/.changeset/try-live-panel-ready-contract.md b/.changeset/try-live-panel-ready-contract.md index 326d6d0ae..b9b6d2a64 100644 --- a/.changeset/try-live-panel-ready-contract.md +++ b/.changeset/try-live-panel-ready-contract.md @@ -1,10 +1,13 @@ --- '@conciv/embed': patch +'@conciv/extension-ios': patch '@conciv/protocol': patch --- -`mount(el)` now resolves only once the widget can actually honor `open()`/`conciv:open-panel` — previously the returned promise settled as soon as the app's boot sequence finished computing, which raced ahead of the root route's `onMount` (where the open-panel listener registers). Any embedder that opens the panel immediately after `mount()` resolves — including a landing page's "Try it live" button that dispatches an early click before the widget bundle finishes loading — no longer has that open silently dropped. +`mount(el)` now resolves only once the widget can actually honor `open()` — previously the returned promise settled as soon as the app's boot sequence finished computing, which raced ahead of the root route's `onMount` (where the open-panel listener registers). Any embedder that opens the panel immediately after `mount()` resolves — including a landing page's "Try it live" button that dispatches an early click before the widget bundle finishes loading — no longer has that open silently dropped. `mountConciv(extensions)` now returns the underlying `mount()` promise instead of `void`, so a caller can `await` it (or observe a rejection) instead of the widget's readiness being unobservable outside `createConciv`. Existing fire-and-forget call sites keep working unchanged; they just ignore the returned promise. -New `@conciv/protocol/event-bus` export: `createEventBusClient`/`createEventBusHost`, a queue-until-connected event handshake (ported from TanStack Devtools' event-bus-client protocol) that eliminates the whole "sender fires before receiver is listening" class of races, not just the one above. `createConciv().open()`/`close()`/`toggle()` now emit through a bus client on the `conciv:panel-commands` channel instead of a bare `window.dispatchEvent`; the widget's root route answers as the bus host once its command listeners are registered. The wire event names (`conciv:open-panel`, `conciv:close-panel`, `conciv:toggle-panel`, `conciv:connection-changed`, `conciv:panel-toggled`) are unchanged, so existing raw dispatchers (the iOS bridge, tests) keep working exactly as before — the bus is additive reliability for callers that opt into it, not a breaking wire-protocol change. +New `@conciv/protocol/event-bus` export: `createEventBus`/`createEventBusClient`, a faithful port of the TanStack Devtools in-page event-bus protocol. It eliminates the whole "sender fires before receiver is listening" class of races, not just the one above. Every emit is wrapped as an envelope (`{type: ':', payload, pluginId}`) and dispatched on one fixed bus event (`conciv-dispatch-event`); the running bus re-dispatches it as both a specific `:` event and a global `conciv-global` event, and answers the fixed `conciv-connect` handshake with `conciv-connect-success`. Clients queue emits until connected, retry on a bounded loop, and flush in order on ack. + +Panel commands moved onto that protocol under the `panel` plugin id, so the wire events are now `panel:open`, `panel:close` and `panel:toggle` instead of `conciv:open-panel`, `conciv:close-panel` and `conciv:toggle-panel`, and they are spoken through a bus client rather than a bare `window.dispatchEvent`. `createConciv().open()`/`close()`/`toggle()`, the landing page's "Try it live" button, and the iOS bridge's panel open/close all emit through a client; the widget's root route subscribes with `client.on()` and starts the bus once its listeners are registered. Status events (`conciv:connection-changed`, `conciv:panel-toggled`) are unchanged raw window events. diff --git a/apps/conciv/src/routes/__root.tsx b/apps/conciv/src/routes/__root.tsx index 56205d34b..78e103eac 100644 --- a/apps/conciv/src/routes/__root.tsx +++ b/apps/conciv/src/routes/__root.tsx @@ -15,13 +15,11 @@ import {createHotkey} from '@tanstack/solid-hotkeys' import {Show, createEffect, createSignal, onCleanup, onMount} from 'solid-js' import {makeEventListener} from '@solid-primitives/event-listener' import { - CLOSE_PANEL_EVENT, CONNECTION_CHANGED_EVENT, - createEventBusHost, - OPEN_PANEL_EVENT, - PANEL_COMMAND_CHANNEL, + createEventBus, + createEventBusClient, + PANEL_PLUGIN_ID, PANEL_TOGGLED_EVENT, - TOGGLE_PANEL_EVENT, type PanelCommandEventMap, } from '@conciv/protocol/event-bus' import type {ConcivRouterContext} from '../router.js' @@ -272,20 +270,27 @@ function RootChrome(props: { onCleanup(() => cancelAnimationFrame(frame)) }) - const panelCommandHost = createEventBusHost({channel: PANEL_COMMAND_CHANNEL}) + const eventBus = createEventBus() + const panelCommands = createEventBusClient({pluginId: PANEL_PLUGIN_ID}) onMount(() => { if (settings.defaultOpen && closedMatch()) void openPanel() makeEventListener(window, 'resize', reportPanelState) - panelCommandHost.on(OPEN_PANEL_EVENT, () => void openPanel()) - panelCommandHost.on(CLOSE_PANEL_EVENT, () => { - if (panelOpen()) closePanel() - }) - panelCommandHost.on(TOGGLE_PANEL_EVENT, () => togglePanel()) - panelCommandHost.ready() + const unsubscribes = [ + panelCommands.on('open', () => void openPanel()), + panelCommands.on('close', () => { + if (panelOpen()) closePanel() + }), + panelCommands.on('toggle', () => togglePanel()), + ] + eventBus.start() notifyInteractive() + onCleanup(() => { + for (const unsubscribe of unsubscribes) unsubscribe() + eventBus.stop() + panelCommands.dispose() + }) }) - onCleanup(() => panelCommandHost.dispose()) const onKeyDown = (event: KeyboardEvent) => { if (event.key !== 'Escape') return diff --git a/apps/site/src/components/landing/try-live-button.tsx b/apps/site/src/components/landing/try-live-button.tsx index 284581c0a..45ef8eefe 100644 --- a/apps/site/src/components/landing/try-live-button.tsx +++ b/apps/site/src/components/landing/try-live-button.tsx @@ -3,8 +3,7 @@ import {Button} from '@/components/ui/button' import { CONNECTION_CHANGED_EVENT, createEventBusClient, - OPEN_PANEL_EVENT, - PANEL_COMMAND_CHANNEL, + PANEL_PLUGIN_ID, type EventBusClientState, type PanelCommandEventMap, type WidgetConnectionChangedDetail, @@ -37,7 +36,7 @@ function useConcivConnected(): boolean { } const panelCommands = createEventBusClient({ - channel: PANEL_COMMAND_CHANNEL, + pluginId: PANEL_PLUGIN_ID, reconnectEveryMs: 500, maxRetries: 60, }) @@ -49,7 +48,7 @@ function useBusState(): EventBusClientState { export function TryLiveButton() { const isConnected = useConcivConnected() const busState = useBusState() - const open = () => panelCommands.emit(OPEN_PANEL_EVENT, undefined) + const open = () => panelCommands.emit('open', undefined) return (