<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
  <title>Writing by Chaitanya Arora</title>
  <subtitle>Notes on things I've built, what broke along the way, and what I changed.</subtitle>
  <link href="https://chaitanyaarora.com/feed.xml" rel="self" type="application/atom+xml"/>
  <link href="https://chaitanyaarora.com/blog/" rel="alternate" type="text/html"/>
  <id>https://chaitanyaarora.com/blog/</id>
  <updated>2026-10-21T00:00:00Z</updated>
  <author><name>Chaitanya Arora</name><uri>https://chaitanyaarora.com/</uri></author>
  <entry>
    <title>Keeping a Chrome extension working after LinkedIn's redesign</title>
    <link href="https://chaitanyaarora.com/blog/keeping-a-chrome-extension-working/" rel="alternate" type="text/html"/>
    <id>https://chaitanyaarora.com/blog/keeping-a-chrome-extension-working/</id>
    <published>2026-10-21T00:00:00Z</published>
    <updated>2026-10-21T00:00:00Z</updated>
    <summary>How I rebuilt a Chrome extension after LinkedIn's pages changed: selectors that survive redesigns, a dialog in the top layer, and fewer permissions.</summary>
    <content type="html">&lt;p&gt;&lt;strong&gt;Short version.&lt;/strong&gt; My Chrome extension, Hashtag ToolTips, stopped working because LinkedIn's pages no longer matched anything it looked for. Class names had turned into generated strings, and the post composer had become a native &lt;code&gt;&amp;lt;dialog&amp;gt;&lt;/code&gt;. The rebuild finds elements by structure that survives redesigns (link addresses, roles, the dialog itself), keeps everything it adds inside that dialog, asks for fewer permissions and is gentle with LinkedIn's servers. This post covers what broke, what I changed, and what the extension still cannot do.&lt;/p&gt;
&lt;h2 id="what-the-extension-does"&gt;What the extension does&lt;/h2&gt;
&lt;p&gt;I built Hashtag ToolTips in 2022 to answer one question while writing a post: how big is this hashtag? Hover a hashtag in the feed and a small tooltip shows its follower count. In the post composer, a "Get Followers" button lists the hashtags in your draft with their counts. The popup has a search box for a single hashtag. It has been on the Chrome Web Store since 2022, and over a thousand people use it every week.&lt;/p&gt;
&lt;p&gt;By 2026 the page had changed enough that the content script found nothing to attach to. Version 1.0.0 is a rebuild of the parts that broke.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://chaitanyaarora.com/assets/blog/hashtag-followers-panel.png" alt="The Hashtag followers panel in a post composer, listing #marketing and #growth with their follower counts and a total" width="1280" height="800" loading="lazy" decoding="async"&gt;&lt;/p&gt;
&lt;h2 id="match-structure-not-class-names"&gt;Match structure, not class names&lt;/h2&gt;
&lt;p&gt;The old selectors relied on class names such as &lt;code&gt;.share-box_actions&lt;/code&gt; and &lt;code&gt;.ql-hashtag&lt;/code&gt;. Those are gone. The new class names are hashed strings that LinkedIn is free to change at any time, so any selector built on them is a bet against the next deploy.&lt;/p&gt;
&lt;p&gt;What stays put is the meaning of the markup. A hashtag in a post is still a link, and its address carries a hashtag search. The composer is still a text box inside a dialog. Two selectors cover both:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;const SELECTORS = {
  hashtagLink: 'a[href*="keywords=%23"], a[href*="/feed/hashtag/"]',
  composerEditor: 'dialog [role="textbox"][contenteditable="true"]',
};
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The hashtag itself is read from the link's address, not from its text, because the visible text can carry hidden accessibility text such as "hashtag#Google". The link text is only the fallback.&lt;/p&gt;
&lt;p&gt;The rule I followed: prefer attributes and roles over class names, and keep every site-specific selector in one block at the top of the file. When something breaks again, that block is the only place to look.&lt;/p&gt;
&lt;h2 id="the-composer-is-a-dialog-in-the-top-layer"&gt;The composer is a dialog in the top layer&lt;/h2&gt;
&lt;p&gt;The post composer is a native &lt;code&gt;&amp;lt;dialog&amp;gt;&lt;/code&gt;. The browser draws an open modal dialog in the top layer, above everything else on the page. Anything appended to &lt;code&gt;document.body&lt;/code&gt; is hidden behind it and cannot be clicked.&lt;/p&gt;
&lt;p&gt;The fix is to add UI inside the dialog whenever the target lives in one:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;const host = link.closest('dialog') ?? document.body;
host.append(tooltip);
&lt;/code&gt;&lt;/pre&gt;

&lt;h2 id="a-hashtag-that-wraps-onto-two-lines"&gt;A hashtag that wraps onto two lines&lt;/h2&gt;
&lt;p&gt;A link that wraps onto two lines has one box per line, but &lt;code&gt;getBoundingClientRect()&lt;/code&gt; returns a single box around both. The tooltip then lands far from the word under the pointer. &lt;code&gt;getClientRects()&lt;/code&gt; returns one rectangle per line, so the tooltip anchors to the line that contains the pointer:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;const rects = [...link.getClientRects()];
const hit = rects.find(
  (r) =&amp;gt; x &amp;gt;= r.left &amp;amp;&amp;amp; x &amp;lt;= r.right &amp;amp;&amp;amp; y &amp;gt;= r.top &amp;amp;&amp;amp; y &amp;lt;= r.bottom
);
const anchor = hit ?? rects[0];
&lt;/code&gt;&lt;/pre&gt;

&lt;h2 id="finding-the-post-button-without-reading-its-label"&gt;Finding the Post button without reading its label&lt;/h2&gt;
&lt;p&gt;The "Get Followers" button has to sit next to the Post button in whatever language the account uses. Matching the text "Post" would mean one translation per language, forever. Instead I take the last button in the composer that is not inside the editor:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;const buttons = [...root.querySelectorAll('button')]
  .filter((button) =&amp;gt; !editor.contains(button));
const post = buttons[buttons.length - 1];
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;That is a bet on layout instead of wording. It is a cheaper bet to lose: if the layout changes, one line needs fixing, not eight languages. The extension's own text is in eight languages through Chrome's i18n system.&lt;/p&gt;
&lt;h2 id="being-a-polite-client"&gt;Being a polite client&lt;/h2&gt;
&lt;p&gt;The extension reads follower counts using your own signed-in session, so it should not hammer anyone's servers. At most three requests run at once. Identical lookups share a single request. Each count is cached for seven days. If the server answers with a rate-limit response, lookups pause for a minute.&lt;/p&gt;
&lt;p&gt;The old cache had a bug worth mentioning. It kept every hashtag in a single &lt;code&gt;storage.sync&lt;/code&gt; item, which is capped at 8 KB, so after about 100 hashtags nothing more could be saved. The new version stores one &lt;code&gt;storage.local&lt;/code&gt; entry per hashtag, removes expired ones on load and migrates the old data the first time it runs.&lt;/p&gt;
&lt;h2 id="asking-for-less"&gt;Asking for less&lt;/h2&gt;
&lt;p&gt;The previous version asked for the &lt;code&gt;tabs&lt;/code&gt; permission, whose install warning reads "Read your browsing history". I removed it. What is left is &lt;code&gt;storage&lt;/code&gt; for the cache, &lt;code&gt;activeTab&lt;/code&gt; so the popup can check that the current tab is LinkedIn, and access to linkedin.com itself, where the content script runs. Chrome still warns about that site access, and it should: it is accurate.&lt;/p&gt;
&lt;p&gt;There is no analytics, no account and nothing sent to me. Lookups go only to linkedin.com. The privacy policy is on the &lt;a href="https://chaitanyaarora.com/hashtag-tooltips/"&gt;extension's page&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id="updating-while-pages-are-open"&gt;Updating while pages are open&lt;/h2&gt;
&lt;p&gt;After an update, a LinkedIn tab that was already open keeps running the old script, now cut off from the extension. Calls through &lt;code&gt;chrome.runtime&lt;/code&gt; start failing. The script checks &lt;code&gt;chrome.runtime?.id&lt;/code&gt;, disconnects its observer and does nothing, instead of throwing errors on every page change. The user has to refresh the tab once, and the popup says so if it cannot reach the page.&lt;/p&gt;
&lt;h2 id="hashtags-are-not-only-ascii"&gt;Hashtags are not only ASCII&lt;/h2&gt;
&lt;p&gt;The pattern for a hashtag includes combining marks:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;const HASHTAG_PATTERN = /#([\p{L}\p{M}\p{N}_]+)/u;
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;code&gt;\p{M}&lt;/code&gt; matters for scripts such as Devanagari and Tamil. Without it, a hashtag is truncated at the first combining mark.&lt;/p&gt;
&lt;h2 id="what-it-still-cannot-do"&gt;What it still cannot do&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;LinkedIn no longer lets people follow hashtags, and no longer suggests them while you type (last checked October 2026). The old "count under each suggestion" feature is gone, and a count now shows how popular a hashtag has been, not how far a post will reach.&lt;/li&gt;
&lt;li&gt;The numbers come from an undocumented endpoint that can change without notice. If it does, lookups fail with a generic error until I update the extension.&lt;/li&gt;
&lt;li&gt;After installing or updating, a LinkedIn tab that was already open needs one refresh.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="keeping-it-alive"&gt;Keeping it alive&lt;/h2&gt;
&lt;p&gt;When it breaks, the fix lives in the block of selectors at the top of the content script. The health check takes two minutes once a month: open the feed, hover a hashtag, and if nothing happens, the markup has changed.&lt;/p&gt;
&lt;p&gt;You can install Hashtag ToolTips from the &lt;a href="https://chromewebstore.google.com/detail/hashtag-tooltips/nkjmngaageipgkolfkappfdckbiehmha"&gt;Chrome Web Store&lt;/a&gt;. I'm Chaitanya Arora. I build transactional backend systems where being wrong is expensive: ledgers, payouts, credit decisions and approval workflows, mostly in Python. The rest of what I have built is on &lt;a href="https://chaitanyaarora.com/"&gt;chaitanyaarora.com&lt;/a&gt;.&lt;/p&gt;</content>
  </entry>
</feed>
