Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,16 @@ Fully **local** detection of 82 technologies — frameworks, CMS / site builders

Every frame is a real capture of the extension scanning real sites (Stripe, Notion, Netflix, Airbnb and more), rendered from code. See [`launch-video/`](launch-video/).

## What’s new in 1.8.1

**TanStack in production.** Real TanStack Start, Router and Query apps ship hashed chunks and no package names, so 1.8.0 missed them — even on tanstack.com. 1.8.1 reads the globals production builds actually expose (`__TSR_ROUTER__`, `__TSS_START_OPTIONS__`) and walks the React tree (bounded: 6,000 fibers / 25 ms) for provider clients that have no global at all: **TanStack Query, TanStack Router, React Router data routers, Redux and Apollo**. Now detected on tanstack.com, railway.com, bolt.new, spotify.com and onlyfrontendjobs.com. Details: [docs/RELEASE-1.8.1.md](docs/RELEASE-1.8.1.md).

**Check any site yourself** (DevTools console) — the same markers WhatStack reads:

```js
({ tanstackRouter: !!window.__TSR_ROUTER__, tanstackStart: !!window.__TSS_START_OPTIONS__ })
```

## What’s new in 1.8.0

A full engine and runtime audit ([docs/AUDIT-2026-09.md](docs/AUDIT-2026-09.md)), with every finding fixed and pinned by a test. Details: [docs/RELEASE-1.8.0.md](docs/RELEASE-1.8.0.md).
Expand Down
49 changes: 49 additions & 0 deletions docs/RELEASE-1.8.1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# WhatStack 1.8.1 — TanStack in production

1.8.0 only recognised TanStack from package names in asset URLs
(`@tanstack/react-query@…`) and from dev-only globals. Production apps ship
hashed chunks, so real TanStack Start, Router and Query apps showed nothing:
tanstack.com itself reported only Tailwind, Sentry and Google Analytics.

## What changed

- **TanStack Router / Start production globals.** Production apps expose
`window.__TSR_ROUTER__` (a router with `routesById` + `buildLocation`) and
`window.__TSS_START_OPTIONS__`. 1.8.0 looked for `__TANSTACK_ROUTER__` /
`__TANSTACK_START__`, which don't exist in production builds.
- **A bounded React-tree walk.** TanStack Query, data routers, Redux and Apollo
have no global in production, but their clients are passed to a provider
component. The MAIN-world probe walks the React tree from the root fiber
(at most 6,000 fibers or 25 ms) and accepts only objects with the library's
shape: a QueryClient has `getQueryCache` + `getMutationCache`, a TanStack
router has `routesById` + `buildLocation`, a React Router data router has
`routes`, `navigate`, `subscribe` and `state`, a Redux store has
`dispatch` / `getState` / `subscribe`, an Apollo client has `watchQuery` /
`query` / `cache`. Works when React hydrates `document` (TanStack Start).
- **Manual chunk names** (`tanstack-router-B-OQbHRF.js`, `tanstack-query-….js`)
count as evidence; image names still never do.
- **Foreign webpack globals.** A consent banner's `webpackChunk_osano_…` on a
Vite-built TanStack Start site is shown as low, like Parcel on Next.js in 1.8.0.

## Measured (real extension, headless Chromium)

| Site | 1.8.0 | 1.8.1 |
|---|---|---|
| tanstack.com | Tailwind, Sentry, GA, Cloudflare | **TanStack Start, Router, Query**, React, Tailwind, Sentry, GA, Cloudflare |
| railway.com | — (not in corpus) | **TanStack Start, Router, Query**, React, … (webpack from a consent banner → low) |
| bolt.new | React, React Router 7.18.2, … | … + **TanStack Query** |
| onlyfrontendjobs.com | Next.js, React, … | … + **TanStack Query** (confirmed: `@tanstack/react-query` in its package.json) |
| airbnb.com, flipkart.com | React, React Router | … + **Redux** |
| netflix.com | React, Webpack, Emotion | … + **Apollo Client** |
| spotify.com | React, React Router, … | … + **Redux, TanStack Query** |

No other result in the 49-site golden corpus changed (nextjs.org's Vercel hint
came from a lazily loaded image and did not load during the second capture).

## Not verifiable from here

- **chatgpt.com (logged out)** is OpenAI's own lightweight "Octane" shell, with
no TanStack, React Router or Remix markers. The logged-in app may differ.
- **lovable.dev** serves a Cloudflare challenge to headless browsers.

Both can be checked in a real browser; see the README.
2 changes: 1 addition & 1 deletion manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "WhatStack",
"version": "1.8.0",
"version": "1.8.1",
"minimum_chrome_version": "102",
"description": "See what any website is built with — frameworks, tools, and libraries. Instant, private stack detection.",
"icons": {
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "whatstack",
"version": "1.8.0",
"version": "1.8.1",
"private": true,
"description": "WhatStack — local Chrome extension that reports a site's frontend tech stack",
"type": "module",
Expand Down
76 changes: 73 additions & 3 deletions shared/detect.js
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,73 @@ export function mainWorldProbeSource() {
}
});

// ── React tree walk ──────────────────────────────────────────
// TanStack Query, data routers, Redux and Apollo usually expose NO global in
// production — but their clients are passed as props to a provider component,
// so they are sitting in the React tree. Walk it from the root fiber with a
// node and time budget, and only accept objects with the library's own shape.
run(() => {
const MAX_FIBERS = 6000;
const MAX_MS = 25;
const now = () => (typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now());
const t0 = now();
const roots = [];
const holders = [document, document.documentElement, document.body].concat(mountCandidates());
for (const h of holders) {
if (!h) continue;
for (const k of ownKeys(h)) {
if (k.indexOf('__reactContainer$') === 0) {
const f = get(h, k);
if (isObj(f) && roots.indexOf(f) === -1) roots.push(f);
}
}
// legacy ReactDOM.render root
const legacy = get(get(get(h, '_reactRootContainer'), '_internalRoot'), 'current');
if (isObj(legacy) && roots.indexOf(legacy) === -1) roots.push(legacy);
}
if (!roots.length) return;
if (!present.__reactContainer) mark('__reactContainer');
const want = ['__tanstackQueryClient', '__tanstackRouterInstance', '__reactRouterDataRouter', '__reduxStore', '__APOLLO_CLIENT__'];
const stack = roots.slice();
let n = 0;
while (stack.length && n < MAX_FIBERS) {
if ((n & 255) === 0 && now() - t0 > MAX_MS) break;
const f = stack.pop();
n++;
const props = get(f, 'memoizedProps');
if (props && typeof props === 'object') {
for (const key of ['client', 'queryClient', 'router', 'store', 'value']) {
const v = get(props, key);
if (!isObj(v)) continue;
if (fnAt(v, 'getQueryCache') && fnAt(v, 'getMutationCache')) {
if (!present.__tanstackQueryClient) mark('__tanstackQueryClient');
} else if (isObj(get(v, 'routesById')) && fnAt(v, 'buildLocation')) {
if (!present.__tanstackRouterInstance) mark('__tanstackRouterInstance');
} else if (Array.isArray(get(v, 'routes')) && fnAt(v, 'navigate') && fnAt(v, 'subscribe') && isObj(get(v, 'state'))) {
if (!present.__reactRouterDataRouter) mark('__reactRouterDataRouter');
} else if (fnAt(v, 'dispatch') && fnAt(v, 'getState') && fnAt(v, 'subscribe')) {
if (!present.__reduxStore) mark('__reduxStore');
} else if (fnAt(v, 'watchQuery') && fnAt(v, 'query') && isObj(get(v, 'cache'))) {
if (!present.__APOLLO_CLIENT__) mark('__APOLLO_CLIENT__');
}
}
}
if (want.every((w) => present[w])) break;
const sib = get(f, 'sibling');
if (isObj(sib)) stack.push(sib);
const child = get(f, 'child');
if (isObj(child)) stack.push(child);
}
});

// ── TanStack Router / Start production globals ───────────────
run(() => {
const r = tryGet('__TSR_ROUTER__');
if (isObj(r) && isObj(get(r, 'routesById')) && fnAt(r, 'buildLocation')) mark('__TSR_ROUTER__');
if (isObj(tryGet('__TSS_START_OPTIONS__')) && !isFn(tryGet('__TSS_START_OPTIONS__'))) mark('__TSS_START_OPTIONS__');
if (isObj(tryGet('$_TSR')) && !isFn(tryGet('$_TSR'))) mark('$_TSR');
});

// ── Next.js ──────────────────────────────────────────────────
run(() => {
if (isObj(tryGet('__NEXT_DATA__')) && !Array.isArray(tryGet('__NEXT_DATA__'))) mark('__NEXT_DATA__');
Expand Down Expand Up @@ -1436,12 +1503,15 @@ const META_BUNDLERS = {
sveltekit: ['vite'],
remix: ['vite'],
astro: ['vite'],
'tanstack-start': ['vite'],
};

/**
* One page, one app bundler. When a meta-framework fixes the bundler, a
* different one seen only as a runtime global (window.parcelRequire from an
* embedded survey widget on a Next.js site) belongs to someone else's script.
* different one seen only as a runtime global belongs to someone else's script:
* window.parcelRequire from an embedded survey widget on a Next.js site, or
* webpackChunk_osano_cmp_consent_manager from a consent banner on a Vite-built
* TanStack Start site (railway.com).
* @param {Hit[]} hits
*/
function demoteConflictingBundlers(hits) {
Expand All @@ -1450,7 +1520,7 @@ function demoteConflictingBundlers(hits) {
if (!owner) return;
const allowed = new Set(META_BUNDLERS[owner]);
for (const h of hits) {
if (!['parcel', 'vite'].includes(h.id) || allowed.has(h.id) || h.confidence === 'low') continue;
if (!['parcel', 'vite', 'webpack'].includes(h.id) || allowed.has(h.id) || h.confidence === 'low') continue;
if (h.evidence.every((e) => e.type === 'global' || e.type === 'inline')) {
h.confidence = 'low';
h.evidence = [
Expand Down
13 changes: 13 additions & 0 deletions shared/signatures.js
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,10 @@ export const SIGNATURES = [
// Legacy package name (pre-TanStack rebrand)
{ type: 'script', pattern: /(?:\/|@)react-query(?:@|\/)/i, weight: 4, strong: true, runtime: true },
{ type: 'global', pattern: '__TANSTACK_QUERY_CLIENT__', weight: 4, strong: true, runtime: true },
// A QueryClient (getQueryCache + getMutationCache) found in the React tree
{ type: 'global', pattern: '__tanstackQueryClient', weight: 5, strong: true, runtime: true },
// Vite/Rolldown manual chunk names used by TanStack apps (tanstack-query-Cs_IuARx.js)
{ type: 'script', pattern: /(?<![\w-])tanstack-query-[\w-]+\.m?js$/i, weight: 3, strong: true },
{ type: 'global', pattern: 'ReactQuery', weight: 3, strong: true, runtime: true },
],
},
Expand All @@ -290,6 +294,13 @@ export const SIGNATURES = [
{ type: 'script', pattern: /@tanstack\/router-core(?:@|\/|$)/i, weight: 5, strong: true, runtime: true },
{ type: 'script', pattern: /(?:unpkg\.com|jsdelivr\.net|esm\.sh)\/.*@tanstack\/(?:react-)?router/i, weight: 5, strong: true, runtime: true },
{ type: 'global', pattern: '__TANSTACK_ROUTER__', weight: 4, strong: true, runtime: true },
// What production TanStack Router / Start apps actually expose (tanstack.com, railway.com, t3.chat)
{ type: 'global', pattern: '__TSR_ROUTER__', weight: 5, strong: true, runtime: true },
// A router (routesById + buildLocation) passed to RouterProvider, found in the React tree
{ type: 'global', pattern: '__tanstackRouterInstance', weight: 5, strong: true, runtime: true },
// SSR dehydration global of earlier router versions
{ type: 'global', pattern: '$_TSR', weight: 4, strong: true, runtime: true },
{ type: 'script', pattern: /(?<![\w-])tanstack-router-[\w-]+\.m?js$/i, weight: 3, strong: true },
{ type: 'inline', pattern: /@tanstack\/(?:react-)?router/i, weight: 3, strong: true, runtime: true },
],
},
Expand Down Expand Up @@ -349,6 +360,8 @@ export const SIGNATURES = [
{ type: 'script', pattern: /(?<![\w-])tanstack[_-]start[\w.-]*\.m?js$/i, weight: 3, strong: true },
{ type: 'inline', pattern: /@tanstack\/(?:react|solid)-start|\bcreateServerFn\s*\(|\bcreateStartHandler\s*\(/, weight: 5, strong: true, runtime: true },
{ type: 'global', pattern: '__TANSTACK_START__', weight: 5, strong: true, runtime: true },
// Set by the TanStack Start client entry in production
{ type: 'global', pattern: '__TSS_START_OPTIONS__', weight: 5, strong: true, runtime: true },
{ type: 'global', pattern: '__TSR_SSR__', weight: 4, strong: true, runtime: true },
{ type: 'meta', pattern: /^generator=.*tanstack\s*start/i, weight: 3, strong: true, runtime: true },
],
Expand Down
2 changes: 1 addition & 1 deletion store/LISTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ WHAT IT DETECTS (examples)
• CMS & site builders: WordPress, Shopify, Webflow, Framer
• Architecture: Module Federation, single-spa, qiankun, SystemJS
• Build: Webpack, Vite, Parcel, Turbopack
• Data: TanStack Query/Router/Table/Form/Virtual, Apollo, SWR, Axios
• Data: TanStack Query/Router/Table/Form/Virtual (including production builds with no package names), Apollo, Redux, SWR, Axios
• UI: Tailwind, Bootstrap, MUI, Emotion, styled-components
• Auth & payments: Auth0, Clerk, Firebase, Stripe, Razorpay
• Observability: Sentry, Datadog, New Relic, LogRocket
Expand Down
31 changes: 31 additions & 0 deletions tests/audit-regressions.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -247,3 +247,34 @@ describe('R6 / L1 — result shaping', () => {
assert.equal(stripQuery('https://a.test/x.js?v=1#h'), 'https://a.test/x.js');
});
});

describe('1.8.1 — TanStack in production', () => {
it('TanStack Start from its production globals (tanstack.com, railway.com)', () => {
const r = run({ globals: { __TSR_ROUTER__: { present: true }, __TSS_START_OPTIONS__: { present: true }, __reactContainer: { present: true } } });
for (const id of ['tanstack-start', 'tanstack-router', 'tanstack', 'react']) assert.ok(hit(r, id), id);
assert.equal(r.primary.id, 'tanstack-start');
});

it('TanStack Query from a QueryClient in the React tree (bolt.new)', () => {
const r = run({ globals: { __tanstackQueryClient: { present: true }, __reactContainer: { present: true } } });
assert.equal(hit(r, 'tanstack-query').confidence, 'high');
assert.ok(hit(r, 'tanstack'));
});

it('manual chunk names count, image names do not', () => {
const r = run({ scripts: ['https://tanstack.com/assets/tanstack-router-B-OQbHRF.js', 'https://tanstack.com/assets/tanstack-query-Cs_IuARx.js'] });
assert.ok(hit(r, 'tanstack-router'));
assert.ok(hit(r, 'tanstack-query'));
assert.deepEqual(ids(run({ scripts: ['https://blog.example/img/tanstack-query-vs-swr.png'] })), []);
});

it("a consent banner's webpackChunk global is not the build of a Vite-based TanStack Start site", () => {
const r = run({ globals: { __TSS_START_OPTIONS__: { present: true }, webpackChunk: { present: true } } });
assert.equal(hit(r, 'webpack').confidence, 'low');
});

it('webpack stays high on Next.js, which builds with it', () => {
const r = run({ scripts: ['/_next/static/chunks/main.js'], globals: { webpackChunk: { present: true } } });
assert.equal(hit(r, 'webpack').confidence, 'high');
});
});
23 changes: 23 additions & 0 deletions tests/browser/fixtures/runtime-tanstack.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Fixture — TanStack Start app (runtime-only proof)</title>
<link rel="stylesheet" href="/_shared.css">
</head>
<body>
<main><h1>TanStack Start app</h1><p>Hashed Vite chunks; no package names in URLs.</p></main>
<script>
// What a production TanStack Start app leaves behind: the router global,
// Start's options global, and a QueryClient passed to QueryClientProvider in
// the React tree (hydrateRoot(document, …) puts the root on document).
window.__TSR_ROUTER__ = { routesById: {}, routeTree: {}, buildLocation: function () {}, navigate: function () {} };
window.__TSS_START_OPTIONS__ = { serializationAdapters: [] };
document['__reactContainer$fx2'] = {
child: { memoizedProps: { client: { getQueryCache: function () {}, getMutationCache: function () {} } }, child: null, sibling: null },
};
document['_reactListeningfx2'] = true;
</script>
<script src="/site.js"></script>
</body>
</html>
6 changes: 6 additions & 0 deletions tests/browser/run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ const CASES = {
'real-tailwind.html': { expect: ['tailwind'], forbid: ['bootstrap'], solid: ['tailwind'] },
// Runtime-only proof: the badge must count it too (background deep pass)
'runtime-react.html': { expect: ['react'], forbid: [], solid: ['react'] },
// 1.8.1 — TanStack Start / Router / Query with no package names in any URL
'runtime-tanstack.html': {
expect: ['tanstack-start', 'tanstack-router', 'tanstack-query', 'tanstack', 'react'],
forbid: [],
solid: ['tanstack-start', 'tanstack-router', 'tanstack-query'],
},
};

// Serve fixtures from inside this process — content_scripts only match
Expand Down
10 changes: 5 additions & 5 deletions tests/corpus/angular.dev.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"host": "angular.dev",
"capturedAt": "2026-09-26",
"whatstack": "1.8.0",
"whatstack": "1.8.1",
"chromium": "151.0.7922.34",
"expect": {
"must": [
Expand Down Expand Up @@ -44,8 +44,6 @@
"https://angular.dev/main-CIUPUTY5.js",
"https://angular.dev/styles-V46FXCBJ.css",
"https://angular.dev/assets/icons/site.webmanifest",
"https://angular.dev/media/editor-light-IWWA7SEJ.png",
"https://angular.dev/media/angular-logo-light-A36B7RRE.svg",
"https://angular.dev/chunk-CmRFN2Lw.js",
"https://angular.dev/chunk-BLjnejr3.js",
"https://angular.dev/chunk-DWSDm00S.js",
Expand All @@ -60,18 +58,20 @@
"https://angular.dev/chunk-Dy8iGyLf.js",
"https://angular.dev/chunk-DpV8IDP5.js",
"https://angular.dev/chunk-BQgwKfs5.js",
"https://angular.dev/media/editor-light-IWWA7SEJ.png",
"https://angular.dev/media/angular-logo-light-A36B7RRE.svg",
"https://angular.dev/assets/others/versions.json",
"https://angular.dev/chunk-zkLfDOfX.js",
"https://angular.dev/assets/images/angie/greeting.svg",
"https://angular.dev/chunk-vgkYz8tD.js",
"https://angular.dev/assets/images/angie/greeting.svg",
"https://angular.dev/chunk-yaDFekiu.js",
"https://angular.dev/assets/icons/favicon.ico",
"https://angular.dev/chunk-C76Gtw6S.js",
"https://angular.dev/chunk-cLhcnb7m.js",
"https://angular.dev/chunk-DY8aYXyj.js",
"https://angular.dev/chunk-CsADmXFc.js",
"https://angular.dev/chunk-BGPBcj8R.js",
"https://angular.dev/chunk-jE6Y_6eY.js",
"https://angular.dev/assets/icons/favicon.ico",
"https://www.google-analytics.com/g/collect"
],
"stylesheets": [
Expand Down
8 changes: 4 additions & 4 deletions tests/corpus/astro.build.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"host": "astro.build",
"capturedAt": "2026-09-26",
"whatstack": "1.8.0",
"whatstack": "1.8.1",
"chromium": "151.0.7922.34",
"expect": {
"must": [
Expand Down Expand Up @@ -30,14 +30,14 @@
"https://astro.build/_astro/page.4AM5fX2K.js",
"https://astro.build/_astro/HeaderNav.astro_astro_type_script_index_0_lang.yswXo7wO.js",
"https://astro.build/_astro/Header.astro_astro_type_script_index_0_lang.DzqrMxS3.js",
"https://astro.build/_astro/CodeBlock.astro_astro_type_script_index_0_lang.Cunj9zFc.js",
"https://astro.build/_astro/HeroBackground.B0iWl89K_1XcMNn.webp",
"https://astro.build/_astro/CodeBlock.astro_astro_type_script_index_0_lang.Cunj9zFc.js",
"https://astro.build/_astro/ec.uumq6.css",
"https://astro.build/_astro/ec.0vx5m.js",
"https://astro.build/_astro/signals-core.module.I2YRmzXw.js",
"https://astro.build/_astro/dom.CNxIm9IU.js",
"https://cdn.usefathom.com/",
"https://astro.build/_astro/signals-core.module.I2YRmzXw.js",
"https://astro.build/fonts/MDIO.woff2",
"https://cdn.usefathom.com/",
"https://astro.build/favicon.svg"
],
"stylesheets": [
Expand Down
Loading
Loading