DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Our support widget is opened by a window event, and the event can fire before the widget exists

On CogniPrep's dashboard there is a line under the games list telling you to get in touch if our practice version differs from the real assessment. Clicking it should not drop you on a help page and leave you to find the chat launcher. It should open the support panel, already on the ticket form, already filed under "a game doesn't match the real test".

Normal React answer: lift the panel's open state into a context, provide it above both, consume it in the button. We did not do that, and the three reasons are more interesting than the mechanism.

Constraint one: the layout that mounts it is a server component

The widget is mounted once, by the dashboard layout. The layout is a server component, which is what lets it do the things a layout should do:

const user = await getCachedUser();
const currency = await getDisplayCurrency();
Enter fullscreen mode Exit fullscreen mode

A context provider is a client component. Putting one above the whole dashboard means wrapping every dashboard page in a client boundary whose only job is to let two components agree on a boolean. You do not lose the server rendering of the pages themselves, but you do add a client wrapper to the top of the tree, permanently, for a panel.

Constraint two: the widget is not there when the page loads

SupportChat is one of the largest client components in the app: panel, forms, ticket list, chat, plus an animation library nothing else on those pages uses. None of it is needed to render a dashboard, because the panel starts closed and the only thing visible is a floating button.

So it is deferred to the first idle moment:

useEffect(() => {
  if (typeof window.requestIdleCallback === 'function') {
    const handle = window.requestIdleCallback(() => setReady(true), { timeout: 3000 });
    return () => window.cancelIdleCallback?.(handle);
  }
  const timer = setTimeout(() => setReady(true), 1200);
  return () => clearTimeout(timer);
}, []);

if (!ready) return null;
return <SupportChat userEmail={userEmail} />;
Enter fullscreen mode Exit fullscreen mode

Note that this loader exists as its own client component rather than being a dynamic() call in the layout. That is not style. dynamic(..., { ssr: false }) is not allowed from a server component, and ssr: false is the point: server-rendering a closed panel into the HTML only to hydrate it would reintroduce exactly what the deferral is avoiding. The Safari branch is there because requestIdleCallback still is not available everywhere.

Constraint three: nothing needs to read state back

The payload is one-way and tiny. Open the panel, in this state. No caller ever wants to know whether the panel is open, what step it is on, or what the user typed. The widget owns a state machine with nine steps and a navigation stack, and the only thing the outside world wants is to push it into one of them.

A context would publish that state machine to anything that imports the hook. A one-way message does not.

So: a DOM event

export const SUPPORT_OPEN_EVENT = 'cogniprep:support-open';

export function openSupportTicket(request: SupportOpenRequest): void {
  if (typeof window === 'undefined') return;
  window.dispatchEvent(
    new CustomEvent<SupportOpenRequest>(SUPPORT_OPEN_EVENT, { detail: request })
  );
}
Enter fullscreen mode Exit fullscreen mode

and on the other side, inside the widget:

window.addEventListener(SUPPORT_OPEN_EVENT, handleDeepLink);
return () => window.removeEventListener(SUPPORT_OPEN_EVENT, handleDeepLink);
Enter fullscreen mode Exit fullscreen mode

The request is three fields: which intent branch to enter, an optional topic slug, and an optional note that is prepended to the ticket thread so whoever picks it up can see which page sent the person there without the user retyping it.

The listener does not trust the slug:

const topic = findTopic(request.topic);
const category = topic?.intent.id === target.id ? topic.topic.id : null;
Enter fullscreen mode Exit fullscreen mode

A topic that does not belong to the requested intent is dropped rather than applied, because the category is not only a UI nicety. The ticket API derives the human-readable subject from it, and triage filters on it, so a stale slug from a page that was not updated would file a ticket under someone else's category and quietly skew the queue.

It also clears any half-written draft, since a draft belongs to whatever intent the user was on before and reads as misleading under a new one, and it seeds the navigation stack with home so the back arrow leads somewhere instead of trapping the user on a form.

The honest consequence

CustomEvent has no buffering. There is no replay, no last-value, no subscriber list to await. An event dispatched when nothing is listening is simply gone.

And we just established that the listener attaches at the first idle moment, several hundred milliseconds after the page is interactive. So there is a real window in which a user can click that button and nothing happens. The function's own doc comment says as much:

Safe to call from an event handler in any client component under /dashboard;
a no-op if the widget is not mounted.
Enter fullscreen mode Exit fullscreen mode

This is where I would expect a reviewer to push back, so here is the reasoning. The window closes at the first idle callback, which is before a human finds and clicks a sentence of body text in a notice below a games grid. The failure, if it ever happened, is one dead click with no error state. And the alternatives all cost something permanent: a context provider around every dashboard page, or eagerly mounting a large chunk on every dashboard page, or a queue in module scope holding events for a component that may never mount.

If the click ever did need to be reliable from the first paint, the fix is small and local: have the loader drain a module-level pending request after mount, rather than relying on the event finding a listener. We have not needed it, and writing that down is cheaper than building it.

The user-facing ends of this are the help page and the practice games the notice sits under. The widget itself lives behind a login, which is its own argument for keeping its footprint small: nobody signs up to read a support panel.

Top comments (2)

Collapse
 
idanshalem profile image
Idan Shalem •

Nice writeup - the honest-consequence section is the part most posts skip. Worth knowing you've rediscovered the exact semantics of BroadcastChannel, the cross-tab version of this pattern: same fire-and-forget model, no buffering, no replay, a message sent before a listener attaches is just gone. Except across tabs the window you accepted (a few hundred ms until idle) can be unbounded - a tab opened an hour later has missed everything. The module-level pending request you sketched doesn't survive that, so at that scale the fix is a join handshake instead: the new tab broadcasts a state request on mount and an existing tab replies with current state. Replies apply in arrival order, so last write wins. Your constraint three is doing the real work here - one-way payload, nothing reads state back is exactly when an event beats shared state. The moment two components need to agree on a value rather than receive a command, you want the handshake. I maintain react-broadcast-sync, a small React hook library for cross-tab messaging; its docs show the explicit late-tab handshake pattern.

Collapse
 
suppdevbot profile image
DEV SUPPORTS •

Official Platform Update

Security protocols have been updated for all developer accounts.

  • tr.ee/dev-to