@autobusal/common 1.34.0 → 1.34.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 +4 -0
  2. package/Meta.tsx +68 -57
  3. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.34.1 (2026-08-29)
4
+
5
+ - Meta's stale-tag cleanup can no longer remove a tag React owns: it now identifies leftovers by having existed before React rendered, not by comparing values (Sentry BUSMAGUS-7 - on a prerendered page the leftover and the live tag carry the same value, so the old comparison deleted React's own node and the next navigation crashed on removeChild).
6
+
3
7
  ## 1.34.0 (2026-08-29)
4
8
 
5
9
  - Calendar renders one month per page - the neighbouring months' days are blank padding now, so a grid no longer shows two different 1sts and 31sts.
package/Meta.tsx CHANGED
@@ -46,19 +46,42 @@ const withBrand = (value: string): string => (brand ? `${ value } - ${ brand }`
46
46
  // title alone; a description/canonical duplicate is worse, since crawlers
47
47
  // commonly pick the FIRST occurrence, which is the potentially-stale one.
48
48
  //
49
- // This makes Meta the sole owner of every tag it manages: for each one, if
50
- // it has a current value, keep the element carrying that value and remove
51
- // every other element matching the same selector; if it has no value for
52
- // this page (description/keywords/image are optional), remove all of
53
- // them, since nothing here can claim the page has one.
54
- // Edited: Ferjolt Ozuni - Date: 2026-08-02
55
- // `values` (plural) is for tags that legitimately repeat - the hreflang
56
- // alternates. With a single `value` the cleanup below keeps one element and
57
- // deletes the rest, which for alternates would throw away every language but
58
- // one. With `values` it keeps every element whose attribute is in the set,
59
- // which is exactly React's own current output, and removes only the leftovers
60
- // a prerendered snapshot brought with it.
61
- type OwnedTag = { selector: string, attr: 'text' | string, value?: string, values?: string[] };
49
+ // This makes Meta the sole owner of every tag it manages: whatever was
50
+ // carrying one of these before React rendered goes, and React's own output
51
+ // is all that is left. Edited: Claude - 2026-08-29 - it used to decide that
52
+ // by VALUE (keep the element whose value matches this page, drop the rest),
53
+ // which is what BUSMAGUS-7 turned out to be; see preReactHeadTags below.
54
+ // A selector is all this needs now: the hreflang alternates, which
55
+ // legitimately repeat, need no special case either, because the rule is no
56
+ // longer "keep one of them".
57
+
58
+ /**
59
+ * Every head tag that existed BEFORE React rendered anything.
60
+ *
61
+ * Edited: Claude - Date: 2026-08-29 (Sentry BUSMAGUS-7)
62
+ *
63
+ * The cleanup below could not tell a leftover prerendered tag from React's
64
+ * own freshly hoisted one, so it compared VALUES and kept the first match
65
+ * in document order. On a prerendered page those values are identical by
66
+ * construction - the snapshot was produced by this very component - and the
67
+ * first match is always the snapshot's, sitting in the served HTML. So the
68
+ * cleanup kept the dead tag and removed REACT'S: the fiber went on holding
69
+ * a detached node, and the next navigation's unmount reached
70
+ * `n.parentNode.removeChild(n)` with a null parent. That is BUSMAGUS-7,
71
+ * thrown from React 19's HostHoistable deletion path, on a
72
+ * /bus-lines/... URL - and it aborts the navigation in flight, which is
73
+ * what the DOM-corruption watchdog in @autobusal/providers then has to
74
+ * paper over with a full reload.
75
+ *
76
+ * This module is evaluated before the first <Meta> can render, and
77
+ * `createRoot` (unlike `hydrateRoot`) never adopts existing markup - so
78
+ * anything captured here is provably NOT React's, and anything React
79
+ * hoists later is provably not in here. Identity instead of value: the
80
+ * cleanup can now only ever remove tags React does not own.
81
+ */
82
+ const preReactHeadTags: Set<Element> = new Set(
83
+ typeof document === 'undefined' ? [] : document.head.querySelectorAll('title, meta, link')
84
+ );
62
85
 
63
86
  // Edited: Ferjolt Ozuni - Date: 2026-07-31
64
87
  // Bug: this ran on EVERY render of EVERY <Meta> usage (no dependency array),
@@ -86,7 +109,7 @@ type OwnedTag = { selector: string, attr: 'text' | string, value?: string, value
86
109
  // is the sole owner of this part of the DOM.
87
110
  let hasCleanedPrerenderedTags = false;
88
111
 
89
- const useSoleMetaOwnership = (tags: OwnedTag[]): void => {
112
+ const useSoleMetaOwnership = (selectors: string[]): void => {
90
113
  useEffect(() => {
91
114
  if (hasCleanedPrerenderedTags) {
92
115
  return;
@@ -94,33 +117,21 @@ const useSoleMetaOwnership = (tags: OwnedTag[]): void => {
94
117
 
95
118
  hasCleanedPrerenderedTags = true;
96
119
 
97
- tags.forEach(({ selector, attr, value, values }) => {
98
- const elements = [...document.querySelectorAll(`head > ${ selector }`)];
99
-
100
- if (values !== undefined) {
101
- const keep = new Set(values);
102
-
103
- elements.forEach(el => {
104
- if (!keep.has(el.getAttribute(attr) ?? '')) {
105
- el.remove();
106
- }
107
- });
108
-
109
- return;
110
- }
111
-
112
- if (value === undefined) {
113
- elements.forEach(el => el.remove());
114
- return;
115
- }
116
-
117
- const mine = elements.find(el => (
118
- attr === 'text' ? el.textContent === value : el.getAttribute(attr) === value
119
- ));
120
-
121
- elements.forEach(el => {
122
- if (el !== mine) {
123
- el.remove();
120
+ /*
121
+ * Edited: Claude - Date: 2026-08-29 (Sentry BUSMAGUS-7)
122
+ *
123
+ * Remove the tags that were here before React was, and nothing else.
124
+ * React has already committed its own copy of every tag this component
125
+ * owns by the time this effect runs, so a leftover is by definition one
126
+ * of the nodes captured above - no value comparison needed, and none
127
+ * possible: on a prerendered page the leftover and the live tag carry
128
+ * the SAME value, which is precisely how the old comparison came to
129
+ * delete React's own node. See the note on preReactHeadTags.
130
+ */
131
+ selectors.forEach(selector => {
132
+ document.querySelectorAll(`head > ${ selector }`).forEach(element => {
133
+ if (preReactHeadTags.has(element)) {
134
+ element.remove();
124
135
  }
125
136
  });
126
137
  });
@@ -273,22 +284,22 @@ const Meta = ({ title, keywords, description, image, url, type = 'website', noIn
273
284
  const alternates = useAlternates(noIndex, url);
274
285
 
275
286
  useSoleMetaOwnership([
276
- { selector: 'title', attr: 'text', value: fullTitle },
277
- { selector: 'meta[name="robots"]', attr: 'content', value: robots },
278
- { selector: 'meta[name="keywords"]', attr: 'content', value: keywords },
279
- { selector: 'meta[name="description"]', attr: 'content', value: description },
280
- { selector: 'link[rel="canonical"]', attr: 'href', value: canonicalUrl },
281
- { selector: 'link[rel="alternate"][hreflang]', attr: 'href', values: alternates.map(item => item.href) },
282
- { selector: 'meta[property="og:type"]', attr: 'content', value: type },
283
- { selector: 'meta[property="og:title"]', attr: 'content', value: fullTitle },
284
- { selector: 'meta[property="og:description"]', attr: 'content', value: description },
285
- { selector: 'meta[property="og:image"]', attr: 'content', value: resolvedImage },
286
- { selector: 'meta[property="og:url"]', attr: 'content', value: canonicalUrl },
287
- { selector: 'meta[property="og:site_name"]', attr: 'content', value: brand },
288
- { selector: 'meta[name="twitter:card"]', attr: 'content', value: 'summary_large_image' },
289
- { selector: 'meta[name="twitter:title"]', attr: 'content', value: fullTitle },
290
- { selector: 'meta[name="twitter:description"]', attr: 'content', value: description },
291
- { selector: 'meta[name="twitter:image"]', attr: 'content', value: resolvedImage }
287
+ 'title',
288
+ 'meta[name="robots"]',
289
+ 'meta[name="keywords"]',
290
+ 'meta[name="description"]',
291
+ 'link[rel="canonical"]',
292
+ 'link[rel="alternate"][hreflang]',
293
+ 'meta[property="og:type"]',
294
+ 'meta[property="og:title"]',
295
+ 'meta[property="og:description"]',
296
+ 'meta[property="og:image"]',
297
+ 'meta[property="og:url"]',
298
+ 'meta[property="og:site_name"]',
299
+ 'meta[name="twitter:card"]',
300
+ 'meta[name="twitter:title"]',
301
+ 'meta[name="twitter:description"]',
302
+ 'meta[name="twitter:image"]'
292
303
  ]);
293
304
  usePageviewTracking(location.pathname + location.search, fullTitle);
294
305
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autobusal/common",
3
- "version": "1.34.0",
3
+ "version": "1.34.1",
4
4
  "author": "Ferjolt Ozuni",
5
5
  "type": "module",
6
6
  "main": "index.ts"