@autobusal/common 1.8.1 → 1.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/Meta.tsx +57 -20
  3. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -3,6 +3,21 @@
3
3
  All notable changes to `@autobusal/common` are documented here. This project follows
4
4
  [Keep a Changelog](https://keepachangelog.com/) and [Semantic Versioning](https://semver.org/).
5
5
 
6
+ ## [1.8.2] - 2026-07-30
7
+
8
+ ### Fixed
9
+
10
+ - **`Meta.tsx` duplicate description/canonical/OG/Twitter tags.** The
11
+ existing title-only dedup (`useSoleTitleOwnership`, since 1.5.1) is now
12
+ `useSoleMetaOwnership` and covers every tag Meta renders, not just
13
+ `<title>`. Surfaced by the new build-time prerender step: React doesn't
14
+ reconcile against tags already sitting in `<head>` from a source other
15
+ than its own current render (a prerendered snapshot's baked-in
16
+ description, a leftover from a pre-locale-detection render, ...), so
17
+ multiple `<meta name="description">`/`og:description`/etc could coexist
18
+ - crawlers commonly read the FIRST occurrence, which could be the stale
19
+ one, not the page's real content.
20
+
6
21
  ## [1.8.1] - 2026-07-30
7
22
 
8
23
  ### Fixed
package/Meta.tsx CHANGED
@@ -31,26 +31,46 @@ const withBrand = (value: string): string => (brand ? `${ value } - ${ brand }`
31
31
  // (verified: zero [data-react-helmet] tags in the DOM on any route, on any
32
32
  // page load, even before this session's changes).
33
33
  //
34
- // React does NOT dedupe <title> between separate component instances -
35
- // @autobusal/providers' <Preload> (the app-shell fallback title, rendered
36
- // above the router) and this component's own <title> both end up as real,
37
- // separate DOM nodes at once - technically invalid HTML (a document must not
38
- // have more than one title element), even though `document.title` itself
39
- // still resolved correctly (per spec it's the FIRST title in tree order,
40
- // which empirically was always this one - but relying on that ordering
41
- // instead of guaranteeing it was fragile). The effect below makes this Meta
42
- // the sole owner of <title>: on mount, it removes every OTHER <title> element
43
- // in <head> - Preload's fallback, or any stray leftover - so exactly one
44
- // title exists once a page has its own Meta.
45
- const useSoleTitleOwnership = (fullTitle: string): void => {
34
+ // React does NOT dedupe these tags against ones that were already sitting in
35
+ // <head> from something OTHER than React's own current render pass - a
36
+ // static tag baked into index.html at build time, or (discovered while
37
+ // building the build-time prerender step) React's own PREVIOUS output
38
+ // baked into a prerendered dist/{path}/index.html snapshot. `createRoot`
39
+ // doesn't adopt/reconcile against existing markup the way `hydrateRoot`
40
+ // would, so every one of these tags - title, description, canonical,
41
+ // og:*, twitter:* - could end up duplicated: a page's real content
42
+ // alongside a stale prerendered description, or (worse) a different
43
+ // page/language's leftover copy. `document.title` happening to still
44
+ // resolve correctly (spec: first <title> in tree order) hid this for
45
+ // title alone; a description/canonical duplicate is worse, since crawlers
46
+ // commonly pick the FIRST occurrence, which is the potentially-stale one.
47
+ //
48
+ // This makes Meta the sole owner of every tag it manages: for each one, if
49
+ // it has a current value, keep the element carrying that value and remove
50
+ // every other element matching the same selector; if it has no value for
51
+ // this page (description/keywords/image are optional), remove all of
52
+ // them, since nothing here can claim the page has one.
53
+ type OwnedTag = { selector: string, attr: 'text' | string, value?: string };
54
+
55
+ const useSoleMetaOwnership = (tags: OwnedTag[]): void => {
46
56
  useEffect(() => {
47
- const mine = [...document.querySelectorAll('head > title')]
48
- .find(el => el.textContent === fullTitle);
57
+ tags.forEach(({ selector, attr, value }) => {
58
+ const elements = [...document.querySelectorAll(`head > ${ selector }`)];
49
59
 
50
- document.querySelectorAll('head > title').forEach(el => {
51
- if (el !== mine) {
52
- el.remove();
60
+ if (value === undefined) {
61
+ elements.forEach(el => el.remove());
62
+ return;
53
63
  }
64
+
65
+ const mine = elements.find(el => (
66
+ attr === 'text' ? el.textContent === value : el.getAttribute(attr) === value
67
+ ));
68
+
69
+ elements.forEach(el => {
70
+ if (el !== mine) {
71
+ el.remove();
72
+ }
73
+ });
54
74
  });
55
75
  });
56
76
  };
@@ -100,8 +120,25 @@ const Meta = ({ title, keywords, description, image, url, type = 'website', noIn
100
120
  const fullTitle = withBrand(title);
101
121
  const canonicalUrl = useCanonicalUrl(url);
102
122
  const location = useLocation();
103
-
104
- useSoleTitleOwnership(fullTitle);
123
+ const robots = noIndex ? 'noindex, nofollow' : 'index, follow';
124
+
125
+ useSoleMetaOwnership([
126
+ { selector: 'title', attr: 'text', value: fullTitle },
127
+ { selector: 'meta[name="robots"]', attr: 'content', value: robots },
128
+ { selector: 'meta[name="keywords"]', attr: 'content', value: keywords },
129
+ { selector: 'meta[name="description"]', attr: 'content', value: description },
130
+ { selector: 'link[rel="canonical"]', attr: 'href', value: canonicalUrl },
131
+ { selector: 'meta[property="og:type"]', attr: 'content', value: type },
132
+ { selector: 'meta[property="og:title"]', attr: 'content', value: fullTitle },
133
+ { selector: 'meta[property="og:description"]', attr: 'content', value: description },
134
+ { selector: 'meta[property="og:image"]', attr: 'content', value: image },
135
+ { selector: 'meta[property="og:url"]', attr: 'content', value: canonicalUrl },
136
+ { selector: 'meta[property="og:site_name"]', attr: 'content', value: brand },
137
+ { selector: 'meta[name="twitter:card"]', attr: 'content', value: 'summary_large_image' },
138
+ { selector: 'meta[name="twitter:title"]', attr: 'content', value: fullTitle },
139
+ { selector: 'meta[name="twitter:description"]', attr: 'content', value: description },
140
+ { selector: 'meta[name="twitter:image"]', attr: 'content', value: image }
141
+ ]);
105
142
  usePageviewTracking(location.pathname + location.search, fullTitle);
106
143
 
107
144
  return (
@@ -109,7 +146,7 @@ const Meta = ({ title, keywords, description, image, url, type = 'website', noIn
109
146
  <title>{ fullTitle }</title>
110
147
 
111
148
  {/* Robots */}
112
- <meta name="robots" content={ noIndex ? 'noindex, nofollow' : 'index, follow' } />
149
+ <meta name="robots" content={ robots } />
113
150
 
114
151
  {/* Standard Meta Tags */}
115
152
  { keywords && <meta name="keywords" content={ keywords } /> }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autobusal/common",
3
- "version": "1.8.1",
3
+ "version": "1.8.2",
4
4
  "author": "Ferjolt Ozuni",
5
5
  "type": "module",
6
6
  "main": "index.ts"