Skip to content

Commit 8ff18e7

Browse files
committed
feat(devtools): add useLocationChanges hook for location change detection
This commit introduces a new hook, useLocationChanges, that allows components to react to changes in the browser's location. The hook sets up listeners for pushState, replaceState, and popstate events, enabling efficient updates when the URL changes. Additionally, it integrates with the SEO tab components to enhance responsiveness to location changes, improving user experience and functionality.
1 parent 25ec4d7 commit 8ff18e7

File tree

4 files changed

+136
-29
lines changed

4 files changed

+136
-29
lines changed
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { onCleanup, onMount } from 'solid-js'
2+
3+
const LOCATION_CHANGE_EVENT = 'tanstack-devtools:locationchange'
4+
5+
type LocationChangeListener = () => void
6+
7+
const listeners = new Set<LocationChangeListener>()
8+
9+
let lastHref = ''
10+
let teardownLocationObservation: (() => void) | undefined
11+
12+
function emitLocationChangeIfNeeded() {
13+
const nextHref = window.location.href
14+
if (nextHref === lastHref) return
15+
lastHref = nextHref
16+
listeners.forEach((listener) => listener())
17+
}
18+
19+
function dispatchLocationChangeEvent() {
20+
window.dispatchEvent(new Event(LOCATION_CHANGE_EVENT))
21+
}
22+
23+
function observeLocationChanges() {
24+
if (teardownLocationObservation) return
25+
26+
lastHref = window.location.href
27+
28+
const originalPushState = window.history.pushState
29+
const originalReplaceState = window.history.replaceState
30+
31+
const handleLocationSignal = () => {
32+
emitLocationChangeIfNeeded()
33+
}
34+
35+
window.history.pushState = function (...args) {
36+
originalPushState.apply(this, args)
37+
dispatchLocationChangeEvent()
38+
}
39+
40+
window.history.replaceState = function (...args) {
41+
originalReplaceState.apply(this, args)
42+
dispatchLocationChangeEvent()
43+
}
44+
45+
window.addEventListener('popstate', handleLocationSignal)
46+
window.addEventListener('hashchange', handleLocationSignal)
47+
window.addEventListener(LOCATION_CHANGE_EVENT, handleLocationSignal)
48+
49+
teardownLocationObservation = () => {
50+
window.history.pushState = originalPushState
51+
window.history.replaceState = originalReplaceState
52+
window.removeEventListener('popstate', handleLocationSignal)
53+
window.removeEventListener('hashchange', handleLocationSignal)
54+
window.removeEventListener(LOCATION_CHANGE_EVENT, handleLocationSignal)
55+
teardownLocationObservation = undefined
56+
}
57+
}
58+
59+
export function useLocationChanges(onChange: () => void) {
60+
onMount(() => {
61+
observeLocationChanges()
62+
listeners.add(onChange)
63+
64+
onCleanup(() => {
65+
listeners.delete(onChange)
66+
if (listeners.size === 0) teardownLocationObservation?.()
67+
})
68+
})
69+
}

packages/devtools/src/tabs/seo-tab/links-preview.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { Section, SectionDescription } from '@tanstack/devtools-ui'
33
import { useStyles } from '../../styles/use-styles'
44
import { pickSeverityClass } from './seo-severity'
55
import type { SeoSeverity } from './seo-severity'
6-
import type { SeoSectionSummary } from './seo-section-summary'
6+
import { countBySeverity, type SeoSectionSummary } from './seo-section-summary'
77

88
type LinkKind = 'internal' | 'external' | 'non-web' | 'invalid'
99

@@ -162,6 +162,7 @@ export function getLinksPreviewSummary(): SeoSectionSummary {
162162
return {
163163
issues: allIssues.slice(0, LINK_SUMMARY_ISSUE_CAP),
164164
issueCount: allIssues.length,
165+
totalCounts: countBySeverity(allIssues),
165166
hint: `${links.length} link(s)`,
166167
}
167168
}

packages/devtools/src/tabs/seo-tab/seo-overview.tsx

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { For, Show, createMemo, createSignal } from 'solid-js'
22
import { Section, SectionDescription } from '@tanstack/devtools-ui'
33
import { useHeadChanges } from '../../hooks/use-head-changes'
4+
import { useLocationChanges } from '../../hooks/use-location-changes'
45
import { useStyles } from '../../styles/use-styles'
56
import { getCanonicalPageData } from './canonical-url-data'
67
import { getSocialPreviewsSummary } from './social-previews'
@@ -13,6 +14,7 @@ import {
1314
aggregateSeoHealth,
1415
countBySeverity,
1516
sectionHealthScore,
17+
totalIssueCount,
1618
worstSeverity,
1719
} from './seo-section-summary'
1820
import type { SeoSeverity } from './seo-severity'
@@ -127,6 +129,10 @@ export function SeoOverviewSection(props: {
127129
setTick((t) => t + 1)
128130
})
129131

132+
useLocationChanges(() => {
133+
setTick((t) => t + 1)
134+
})
135+
130136
const bundle = createMemo(() => {
131137
void tick()
132138
const canonical = getCanonicalPageData()
@@ -321,15 +327,13 @@ export function SeoOverviewSection(props: {
321327
<div class={styles().seoOverviewCheckList}>
322328
<For each={bundle().rows}>
323329
{(row) => {
324-
const sev = worstSeverity(row.summary.issues)
325-
const c = countBySeverity(row.summary.issues)
326-
const subsectionScore = sectionHealthScore(row.summary.issues)
327-
const totalIssues =
328-
row.summary.issueCount ?? row.summary.issues.length
330+
const sev = worstSeverity(row.summary)
331+
const c = countBySeverity(row.summary)
332+
const subsectionScore = sectionHealthScore(row.summary)
333+
const totalIssues = totalIssueCount(row.summary)
329334
const cappedSuffix =
330-
row.summary.issueCount != null &&
331-
row.summary.issueCount > row.summary.issues.length
332-
? ` (${row.summary.issues.length} of ${row.summary.issueCount} listed)`
335+
totalIssues > row.summary.issues.length
336+
? ` (${row.summary.issues.length} of ${totalIssues} listed)`
333337
: ''
334338
const issueLine =
335339
totalIssues === 0

packages/devtools/src/tabs/seo-tab/seo-section-summary.ts

Lines changed: 53 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ export type SeoIssue = {
66
message: string
77
}
88

9+
export type SeoIssueCounts = {
10+
error: number
11+
warning: number
12+
info: number
13+
}
14+
915
/**
1016
* Summary of one SEO subsection for the overview: issues plus an optional
1117
* one-line hint (counts, presence, etc.).
@@ -15,6 +21,8 @@ export type SeoSectionSummary = {
1521
hint?: string
1622
/** When `issues` is capped, total issues before capping. */
1723
issueCount?: number
24+
/** Per-severity totals before any display cap is applied. */
25+
totalCounts?: SeoIssueCounts
1826
}
1927

2028
export type SeoDetailView =
@@ -24,31 +32,48 @@ export type SeoDetailView =
2432
| 'heading-structure'
2533
| 'links-preview'
2634

27-
export function worstSeverity(issues: Array<SeoIssue>): SeoSeverity | null {
28-
if (issues.some((i) => i.severity === 'error')) return 'error'
29-
if (issues.some((i) => i.severity === 'warning')) return 'warning'
30-
if (issues.some((i) => i.severity === 'info')) return 'info'
31-
return null
35+
function countRawIssues(issues: Array<SeoIssue>): SeoIssueCounts {
36+
return issues.reduce<SeoIssueCounts>(
37+
(counts, issue) => {
38+
counts[issue.severity] += 1
39+
return counts
40+
},
41+
{ error: 0, warning: 0, info: 0 },
42+
)
3243
}
3344

34-
export function countBySeverity(issues: Array<SeoIssue>): {
35-
error: number
36-
warning: number
37-
info: number
38-
} {
39-
return {
40-
error: issues.filter((i) => i.severity === 'error').length,
41-
warning: issues.filter((i) => i.severity === 'warning').length,
42-
info: issues.filter((i) => i.severity === 'info').length,
43-
}
45+
export function countBySeverity(
46+
summaryOrIssues: SeoSectionSummary | Array<SeoIssue>,
47+
): SeoIssueCounts {
48+
return Array.isArray(summaryOrIssues)
49+
? countRawIssues(summaryOrIssues)
50+
: (summaryOrIssues.totalCounts ?? countRawIssues(summaryOrIssues.issues))
51+
}
52+
53+
export function totalIssueCount(summary: SeoSectionSummary): number {
54+
if (summary.issueCount != null) return summary.issueCount
55+
const counts = countBySeverity(summary)
56+
return counts.error + counts.warning + counts.info
57+
}
58+
59+
export function worstSeverity(
60+
summaryOrIssues: SeoSectionSummary | Array<SeoIssue>,
61+
): SeoSeverity | null {
62+
const counts = countBySeverity(summaryOrIssues)
63+
if (counts.error > 0) return 'error'
64+
if (counts.warning > 0) return 'warning'
65+
if (counts.info > 0) return 'info'
66+
return null
4467
}
4568

4669
/**
4770
* 0–100 health for one subsection’s issues, using the same penalty weights as
4871
* aggregateSeoHealth().
4972
*/
50-
export function sectionHealthScore(issues: Array<SeoIssue>): number {
51-
const counts = countBySeverity(issues)
73+
export function sectionHealthScore(
74+
summaryOrIssues: SeoSectionSummary | Array<SeoIssue>,
75+
): number {
76+
const counts = countBySeverity(summaryOrIssues)
5277
const penalty = Math.min(
5378
100,
5479
counts.error * 22 + counts.warning * 9 + counts.info * 2,
@@ -63,10 +88,18 @@ export function sectionHealthScore(issues: Array<SeoIssue>): number {
6388
export function aggregateSeoHealth(summaries: Array<SeoSectionSummary>): {
6489
score: number
6590
label: 'Good' | 'Fair' | 'Poor'
66-
counts: ReturnType<typeof countBySeverity>
91+
counts: SeoIssueCounts
6792
} {
68-
const all = summaries.flatMap((s) => s.issues)
69-
const counts = countBySeverity(all)
93+
const counts = summaries.reduce<SeoIssueCounts>(
94+
(allCounts, summary) => {
95+
const summaryCounts = countBySeverity(summary)
96+
allCounts.error += summaryCounts.error
97+
allCounts.warning += summaryCounts.warning
98+
allCounts.info += summaryCounts.info
99+
return allCounts
100+
},
101+
{ error: 0, warning: 0, info: 0 },
102+
)
70103
const penalty = Math.min(
71104
100,
72105
counts.error * 22 + counts.warning * 9 + counts.info * 2,

0 commit comments

Comments
 (0)