@autobusal/common 1.5.0 → 1.6.0

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 +28 -0
  2. package/Meta.tsx +84 -37
  3. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -3,6 +3,34 @@
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.6.0] - 2026-07-30
7
+
8
+ ### Added
9
+
10
+ - **`Meta` now emits a canonical `<link>` and `og:url` on every page by
11
+ default**, even when the caller passes no `url` prop - previously `url`
12
+ was never passed at any of this component's ~136 call sites across both
13
+ apps, so no page anywhere had a canonical tag. Defaults to the current
14
+ path (`location.pathname`, via `react-router-dom`'s `useLocation`) with
15
+ the origin prepended - query strings are naturally excluded, so a search
16
+ results page with `?sfrom=X&sto=Y` or a paginated `?page=N` correctly
17
+ canonicalizes to its bare path instead of creating unbounded near-duplicate
18
+ URLs. `url` can still be passed explicitly to override.
19
+
20
+ ## [1.5.1] - 2026-07-30
21
+
22
+ ### Fixed
23
+
24
+ - **`Meta.tsx` and `@autobusal/providers`'s `Preload.tsx` both render a
25
+ `<title>`, and React 19's native hoisting (added in 1.5.0, see above) does
26
+ NOT dedupe `<title>` between separate component instances.** Both ended up
27
+ as real, simultaneous DOM nodes - technically invalid HTML (a document must
28
+ not have more than one title element), even though `document.title` itself
29
+ still resolved correctly by spec ("first title in tree order" happened to
30
+ always be this one). `Meta` now takes exclusive ownership on mount: it
31
+ removes any other `<title>` element in `<head>` the instant its own real
32
+ page title exists, so exactly one survives.
33
+
6
34
  ## [1.5.0] - 2026-07-30
7
35
 
8
36
  ### Fixed
package/Meta.tsx CHANGED
@@ -1,3 +1,6 @@
1
+ import { useEffect } from 'react';
2
+ import { useLocation } from 'react-router-dom';
3
+
1
4
  interface Props {
2
5
  title: string
3
6
  keywords?: string
@@ -19,45 +22,89 @@ const brand = import.meta.env.VITE_APP_NAME as string | undefined;
19
22
 
20
23
  const withBrand = (value: string): string => (brand ? `${ value } - ${ brand }` : value);
21
24
 
22
- // React 19 automatically hoists any <title>/<meta>/<link> rendered ANYWHERE
23
- // in the component tree into <head>, deduplicating <title> and meta[name=...]
24
- // tags (last-rendered wins) - no wrapper component needed. This replaced
25
+ // React 19 automatically hoists any <title>/<meta>/<link> rendered ANYWHERE in
26
+ // the component tree into <head> - no wrapper component needed. This replaced
25
27
  // react-helmet (6.1.0, unmaintained since ~2021), which had gone completely
26
28
  // non-functional under React 19: none of its DOM writes were ever committing
27
29
  // (verified: zero [data-react-helmet] tags in the DOM on any route, on any
28
- // page load, even before this session's changes). Since Preload.tsx (this
29
- // package's sibling app-shell fallback) renders its own <title> higher in the
30
- // tree, React's last-wins dedup means whichever page's <Meta> renders here -
31
- // deeper in the tree, so later in render order - correctly overrides it, the
32
- // same behavior Helmet was supposed to provide but silently didn't.
33
- const Meta = ({ title, keywords, description, image, url, type = 'website', noIndex = false, children }: Props): JSX.Element => (
34
- <>
35
- <title>{ withBrand(title) }</title>
36
-
37
- {/* Robots */}
38
- <meta name="robots" content={ noIndex ? 'noindex, nofollow' : 'index, follow' } />
39
-
40
- {/* Standard Meta Tags */}
41
- { keywords && <meta name="keywords" content={ keywords } /> }
42
- { description && <meta name="description" content={ description } /> }
43
- { url && <link rel="canonical" href={ url } /> }
44
-
45
- {/* Open Graph / Facebook */}
46
- <meta property="og:type" content={ type } />
47
- <meta property="og:title" content={ withBrand(title) } />
48
- { description && <meta property="og:description" content={ description } /> }
49
- { image && <meta property="og:image" content={ image } /> }
50
- { url && <meta property="og:url" content={ url } /> }
51
- { brand && <meta property="og:site_name" content={ brand } /> }
52
-
53
- {/* Twitter */}
54
- <meta name="twitter:card" content="summary_large_image" />
55
- <meta name="twitter:title" content={ withBrand(title) } />
56
- { description && <meta name="twitter:description" content={ description } /> }
57
- { image && <meta name="twitter:image" content={ image } /> }
58
-
59
- { children }
60
- </>
61
- );
30
+ // page load, even before this session's changes).
31
+ //
32
+ // React does NOT dedupe <title> between separate component instances -
33
+ // @autobusal/providers' <Preload> (the app-shell fallback title, rendered
34
+ // above the router) and this component's own <title> both end up as real,
35
+ // separate DOM nodes at once - technically invalid HTML (a document must not
36
+ // have more than one title element), even though `document.title` itself
37
+ // still resolved correctly (per spec it's the FIRST title in tree order,
38
+ // which empirically was always this one - but relying on that ordering
39
+ // instead of guaranteeing it was fragile). The effect below makes this Meta
40
+ // the sole owner of <title>: on mount, it removes every OTHER <title> element
41
+ // in <head> - Preload's fallback, or any stray leftover - so exactly one
42
+ // title exists once a page has its own Meta.
43
+ const useSoleTitleOwnership = (fullTitle: string): void => {
44
+ useEffect(() => {
45
+ const mine = [...document.querySelectorAll('head > title')]
46
+ .find(el => el.textContent === fullTitle);
47
+
48
+ document.querySelectorAll('head > title').forEach(el => {
49
+ if (el !== mine) {
50
+ el.remove();
51
+ }
52
+ });
53
+ });
54
+ };
55
+
56
+ // `url` was never passed at any of this component's ~136 call sites across
57
+ // both apps - every page shipped with no canonical tag at all, which is a
58
+ // real problem given the 9-segment /bus-lines search URLs and ?sfrom=/?sto=/
59
+ // ?page= parameters create effectively unbounded near-duplicate-content URL
60
+ // space. Rather than thread an explicit `url` through every single call site
61
+ // (easy to miss, easy to get subtly wrong per-page), default it to the
62
+ // current path - React Router's `location.pathname` naturally excludes the
63
+ // query string, so a search results page with ?sfrom=X&sto=Y canonicalizes
64
+ // to its bare /bus-lines/{from}/{to}/... path, and a static page's own path
65
+ // is already the correct canonical. Callers can still pass `url` explicitly
66
+ // to override (e.g. to canonicalize a paginated URL back to page 1).
67
+ const useCanonicalUrl = (explicit?: string): string => {
68
+ const location = useLocation();
69
+
70
+ return explicit ?? `${ window.location.origin }${ location.pathname }`;
71
+ };
72
+
73
+ const Meta = ({ title, keywords, description, image, url, type = 'website', noIndex = false, children }: Props): JSX.Element => {
74
+ const fullTitle = withBrand(title);
75
+ const canonicalUrl = useCanonicalUrl(url);
76
+
77
+ useSoleTitleOwnership(fullTitle);
78
+
79
+ return (
80
+ <>
81
+ <title>{ fullTitle }</title>
82
+
83
+ {/* Robots */}
84
+ <meta name="robots" content={ noIndex ? 'noindex, nofollow' : 'index, follow' } />
85
+
86
+ {/* Standard Meta Tags */}
87
+ { keywords && <meta name="keywords" content={ keywords } /> }
88
+ { description && <meta name="description" content={ description } /> }
89
+ <link rel="canonical" href={ canonicalUrl } />
90
+
91
+ {/* Open Graph / Facebook */}
92
+ <meta property="og:type" content={ type } />
93
+ <meta property="og:title" content={ fullTitle } />
94
+ { description && <meta property="og:description" content={ description } /> }
95
+ { image && <meta property="og:image" content={ image } /> }
96
+ <meta property="og:url" content={ canonicalUrl } />
97
+ { brand && <meta property="og:site_name" content={ brand } /> }
98
+
99
+ {/* Twitter */}
100
+ <meta name="twitter:card" content="summary_large_image" />
101
+ <meta name="twitter:title" content={ fullTitle } />
102
+ { description && <meta name="twitter:description" content={ description } /> }
103
+ { image && <meta name="twitter:image" content={ image } /> }
104
+
105
+ { children }
106
+ </>
107
+ );
108
+ };
62
109
 
63
110
  export default Meta;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autobusal/common",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "author": "Ferjolt Ozuni",
5
5
  "type": "module",
6
6
  "main": "index.ts"