@autobusal/common 1.5.0 → 1.5.1

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 +14 -0
  2. package/Meta.tsx +65 -37
  3. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -3,6 +3,20 @@
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.5.1] - 2026-07-30
7
+
8
+ ### Fixed
9
+
10
+ - **`Meta.tsx` and `@autobusal/providers`'s `Preload.tsx` both render a
11
+ `<title>`, and React 19's native hoisting (added in 1.5.0, see above) does
12
+ NOT dedupe `<title>` between separate component instances.** Both ended up
13
+ as real, simultaneous DOM nodes - technically invalid HTML (a document must
14
+ not have more than one title element), even though `document.title` itself
15
+ still resolved correctly by spec ("first title in tree order" happened to
16
+ always be this one). `Meta` now takes exclusive ownership on mount: it
17
+ removes any other `<title>` element in `<head>` the instant its own real
18
+ page title exists, so exactly one survives.
19
+
6
20
  ## [1.5.0] - 2026-07-30
7
21
 
8
22
  ### Fixed
package/Meta.tsx CHANGED
@@ -1,3 +1,5 @@
1
+ import { useEffect } from 'react';
2
+
1
3
  interface Props {
2
4
  title: string
3
5
  keywords?: string
@@ -19,45 +21,71 @@ const brand = import.meta.env.VITE_APP_NAME as string | undefined;
19
21
 
20
22
  const withBrand = (value: string): string => (brand ? `${ value } - ${ brand }` : value);
21
23
 
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
24
+ // React 19 automatically hoists any <title>/<meta>/<link> rendered ANYWHERE in
25
+ // the component tree into <head> - no wrapper component needed. This replaced
25
26
  // react-helmet (6.1.0, unmaintained since ~2021), which had gone completely
26
27
  // non-functional under React 19: none of its DOM writes were ever committing
27
28
  // (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
- );
29
+ // page load, even before this session's changes).
30
+ //
31
+ // React does NOT dedupe <title> between separate component instances -
32
+ // @autobusal/providers' <Preload> (the app-shell fallback title, rendered
33
+ // above the router) and this component's own <title> both end up as real,
34
+ // separate DOM nodes at once - technically invalid HTML (a document must not
35
+ // have more than one title element), even though `document.title` itself
36
+ // still resolved correctly (per spec it's the FIRST title in tree order,
37
+ // which empirically was always this one - but relying on that ordering
38
+ // instead of guaranteeing it was fragile). The effect below makes this Meta
39
+ // the sole owner of <title>: on mount, it removes every OTHER <title> element
40
+ // in <head> - Preload's fallback, or any stray leftover - so exactly one
41
+ // title exists once a page has its own Meta.
42
+ const useSoleTitleOwnership = (fullTitle: string): void => {
43
+ useEffect(() => {
44
+ const mine = [...document.querySelectorAll('head > title')]
45
+ .find(el => el.textContent === fullTitle);
46
+
47
+ document.querySelectorAll('head > title').forEach(el => {
48
+ if (el !== mine) {
49
+ el.remove();
50
+ }
51
+ });
52
+ });
53
+ };
54
+
55
+ const Meta = ({ title, keywords, description, image, url, type = 'website', noIndex = false, children }: Props): JSX.Element => {
56
+ const fullTitle = withBrand(title);
57
+
58
+ useSoleTitleOwnership(fullTitle);
59
+
60
+ return (
61
+ <>
62
+ <title>{ fullTitle }</title>
63
+
64
+ {/* Robots */}
65
+ <meta name="robots" content={ noIndex ? 'noindex, nofollow' : 'index, follow' } />
66
+
67
+ {/* Standard Meta Tags */}
68
+ { keywords && <meta name="keywords" content={ keywords } /> }
69
+ { description && <meta name="description" content={ description } /> }
70
+ { url && <link rel="canonical" href={ url } /> }
71
+
72
+ {/* Open Graph / Facebook */}
73
+ <meta property="og:type" content={ type } />
74
+ <meta property="og:title" content={ fullTitle } />
75
+ { description && <meta property="og:description" content={ description } /> }
76
+ { image && <meta property="og:image" content={ image } /> }
77
+ { url && <meta property="og:url" content={ url } /> }
78
+ { brand && <meta property="og:site_name" content={ brand } /> }
79
+
80
+ {/* Twitter */}
81
+ <meta name="twitter:card" content="summary_large_image" />
82
+ <meta name="twitter:title" content={ fullTitle } />
83
+ { description && <meta name="twitter:description" content={ description } /> }
84
+ { image && <meta name="twitter:image" content={ image } /> }
85
+
86
+ { children }
87
+ </>
88
+ );
89
+ };
62
90
 
63
91
  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.5.1",
4
4
  "author": "Ferjolt Ozuni",
5
5
  "type": "module",
6
6
  "main": "index.ts"