@weaverse/hydrogen 5.20.0 → 5.20.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.
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Structural comparison used to detect whether a reused Weaverse instance
3
+ * must adopt a fresh loader payload.
4
+ *
5
+ * `JSON.stringify` cannot do this job on the client:
6
+ *
7
+ * - Route loader data reaches `dataContext` unserialized, so deferred values
8
+ * are still `Promise`s. React 19 development builds hang an enumerable,
9
+ * self-referencing `_debugInfo` off those promises, and stringifying one
10
+ * throws `TypeError: Converting circular structure to JSON` during render
11
+ * (issue #511).
12
+ * - In production every promise stringifies to `{}`, so a *fresh* deferred
13
+ * value looks unchanged and the reused instance keeps serving the previous
14
+ * render's promise. Tokenizing promises to a constant (`'[[promise]]'`) has
15
+ * the same defect — it is stable by construction, therefore blind to
16
+ * identity.
17
+ *
18
+ * Semantics, chosen deliberately:
19
+ *
20
+ * - **Promises/thenables are atomic**: compared by identity, never traversed.
21
+ * A fresh promise is a change; the same promise is not. Debug metadata is
22
+ * invisible to the comparison.
23
+ * - **Opaque objects are atomic**: anything that is not a plain object, an
24
+ * array, or `toJSON`-serializable (`Map`, `Set`, `URL`, `RegExp`, streams,
25
+ * async iterators, cross-realm promises, class instances) is compared by
26
+ * identity. `JSON.stringify` flattens all of them to `{}`, which would
27
+ * silently collapse two distinct async values into "unchanged".
28
+ * - **Cyclic graphs are compared coinductively**: a pair already being
29
+ * compared is assumed equal, so equal self/mutual cycles are equal while a
30
+ * differing leaf anywhere still reports a change. Nothing throws.
31
+ * - **JSON-visible fields only**: `undefined`/function/symbol properties are
32
+ * omitted from objects and read as `null` in arrays, and NaN/±Infinity read
33
+ * as `null` — matching the previous `JSON.stringify` behavior for plain wire
34
+ * payloads. Key order is irrelevant, which removes the old "false mismatch
35
+ * costs a re-render" caveat.
36
+ */
37
+ /** Compares two loader payloads for JSON-visible structural equality. */
38
+ export declare function isSameLoaderPayload(left: unknown, right: unknown): boolean;
39
+ //# sourceMappingURL=is-same-loader-payload.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"is-same-loader-payload.d.ts","sourceRoot":"","sources":["../../../src/utils/is-same-loader-payload.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AAqCH,yEAAyE;AACzE,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,OAAO,EAAE,KAAK,EAAE,OAAO,GAAG,OAAO,CAiH1E"}
@@ -13,9 +13,11 @@ import type { WeaverseHydrogen } from '../WeaverseHydrogenRoot';
13
13
  * (unsaved drafts must not be clobbered) and applies updates via
14
14
  * `refreshStudio` instead.
15
15
  *
16
- * Change detection uses JSON comparison — loader payloads are
17
- * wire-serialized, so key order is stable between runs; a false mismatch
18
- * only costs one extra re-render.
16
+ * Change detection uses {@link isSameLoaderPayload}: a structural comparison
17
+ * that treats promises/thenables and other opaque objects as atomic values
18
+ * compared by identity, and tolerates cyclic graphs. `dataContext` carries
19
+ * live route loader data — including unresolved deferred promises whose React
20
+ * development metadata is cyclic — so it cannot be JSON-serialized here.
19
21
  */
20
22
  export declare function syncReusedInstance(weaverse: WeaverseHydrogen, params: WeaverseHydrogenParams): void;
21
23
  //# sourceMappingURL=sync-reused-instance.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"sync-reused-instance.d.ts","sourceRoot":"","sources":["../../../src/utils/sync-reused-instance.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,UAAU,CAAA;AACtD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,yBAAyB,CAAA;AAE/D;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,kBAAkB,CAChC,QAAQ,EAAE,gBAAgB,EAC1B,MAAM,EAAE,sBAAsB,QAkC/B"}
1
+ {"version":3,"file":"sync-reused-instance.d.ts","sourceRoot":"","sources":["../../../src/utils/sync-reused-instance.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,UAAU,CAAA;AACtD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,yBAAyB,CAAA;AAG/D;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,kBAAkB,CAChC,QAAQ,EAAE,gBAAgB,EAC1B,MAAM,EAAE,sBAAsB,QAkC/B"}
@@ -29,5 +29,4 @@ import type { WeaverseHydrogen } from '../index';
29
29
  */
30
30
  export declare function useStudioConnect(): void;
31
31
  export declare function useStudio(weaverse: WeaverseHydrogen): void;
32
- export declare function usePixel(context: WeaverseHydrogen): void;
33
32
  //# sourceMappingURL=use-studio.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"use-studio.d.ts","sourceRoot":"","sources":["../../../src/utils/use-studio.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,UAAU,CAAA;AAoChD;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,wBAAgB,gBAAgB,SAW/B;AAED,wBAAgB,SAAS,CAAC,QAAQ,EAAE,gBAAgB,QAkDnD;AAED,wBAAgB,QAAQ,CAAC,OAAO,EAAE,gBAAgB,QA6BjD"}
1
+ {"version":3,"file":"use-studio.d.ts","sourceRoot":"","sources":["../../../src/utils/use-studio.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,UAAU,CAAA;AA+BhD;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,wBAAgB,gBAAgB,SAW/B;AAED,wBAAgB,SAAS,CAAC,QAAQ,EAAE,gBAAgB,QAiDnD"}
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@weaverse/hydrogen",
3
3
  "author": "Weaverse Team",
4
4
  "description": "Components, hooks, and utilities for building Shopify Hydrogen websites with Weaverse",
5
- "version": "5.20.0",
5
+ "version": "5.20.2",
6
6
  "license": "MIT",
7
7
  "main": "dist/index.js",
8
8
  "typings": "dist/types/index.d.ts",
@@ -46,8 +46,8 @@
46
46
  "dependencies": {
47
47
  "@shopify/hydrogen": ">=2025.5",
48
48
  "@shopify/remix-oxygen": "3",
49
- "@weaverse/react": "5.20.0",
50
- "@weaverse/schema": "0.13.1",
49
+ "@weaverse/react": "5.20.2",
50
+ "@weaverse/schema": "0.14.0",
51
51
  "react": ">=19",
52
52
  "react-dom": ">=19",
53
53
  "react-error-boundary": "^6",
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Structural comparison used to detect whether a reused Weaverse instance
3
+ * must adopt a fresh loader payload.
4
+ *
5
+ * `JSON.stringify` cannot do this job on the client:
6
+ *
7
+ * - Route loader data reaches `dataContext` unserialized, so deferred values
8
+ * are still `Promise`s. React 19 development builds hang an enumerable,
9
+ * self-referencing `_debugInfo` off those promises, and stringifying one
10
+ * throws `TypeError: Converting circular structure to JSON` during render
11
+ * (issue #511).
12
+ * - In production every promise stringifies to `{}`, so a *fresh* deferred
13
+ * value looks unchanged and the reused instance keeps serving the previous
14
+ * render's promise. Tokenizing promises to a constant (`'[[promise]]'`) has
15
+ * the same defect — it is stable by construction, therefore blind to
16
+ * identity.
17
+ *
18
+ * Semantics, chosen deliberately:
19
+ *
20
+ * - **Promises/thenables are atomic**: compared by identity, never traversed.
21
+ * A fresh promise is a change; the same promise is not. Debug metadata is
22
+ * invisible to the comparison.
23
+ * - **Opaque objects are atomic**: anything that is not a plain object, an
24
+ * array, or `toJSON`-serializable (`Map`, `Set`, `URL`, `RegExp`, streams,
25
+ * async iterators, cross-realm promises, class instances) is compared by
26
+ * identity. `JSON.stringify` flattens all of them to `{}`, which would
27
+ * silently collapse two distinct async values into "unchanged".
28
+ * - **Cyclic graphs are compared coinductively**: a pair already being
29
+ * compared is assumed equal, so equal self/mutual cycles are equal while a
30
+ * differing leaf anywhere still reports a change. Nothing throws.
31
+ * - **JSON-visible fields only**: `undefined`/function/symbol properties are
32
+ * omitted from objects and read as `null` in arrays, and NaN/±Infinity read
33
+ * as `null` — matching the previous `JSON.stringify` behavior for plain wire
34
+ * payloads. Key order is irrelevant, which removes the old "false mismatch
35
+ * costs a re-render" caveat.
36
+ */
37
+
38
+ /** `JSON.stringify` drops these from objects and nulls them inside arrays. */
39
+ function isJsonOmitted(value: unknown): boolean {
40
+ return (
41
+ value === undefined ||
42
+ typeof value === 'function' ||
43
+ typeof value === 'symbol'
44
+ )
45
+ }
46
+
47
+ function isObjectLike(value: unknown): value is Record<string, unknown> {
48
+ return value !== null && typeof value === 'object'
49
+ }
50
+
51
+ /** Duck-typed so cross-realm promises and hand-rolled thenables also match. */
52
+ function isThenable(value: Record<string, unknown>): boolean {
53
+ return typeof value.then === 'function'
54
+ }
55
+
56
+ function isPlainObject(value: Record<string, unknown>): boolean {
57
+ const proto = Object.getPrototypeOf(value)
58
+ return proto === Object.prototype || proto === null
59
+ }
60
+
61
+ /** Returns the `toJSON()` projection, or the value itself when there is none. */
62
+ function toJsonValue(value: Record<string, unknown>): unknown {
63
+ return typeof value.toJSON === 'function'
64
+ ? (value.toJSON as () => unknown)()
65
+ : value
66
+ }
67
+
68
+ /** Maps NaN/±Infinity to `null`, matching how `JSON.stringify` renders them. */
69
+ function toFiniteOrNull(value: unknown): unknown {
70
+ return typeof value === 'number' && !Number.isFinite(value) ? null : value
71
+ }
72
+
73
+ /** Compares two loader payloads for JSON-visible structural equality. */
74
+ export function isSameLoaderPayload(left: unknown, right: unknown): boolean {
75
+ // Pairs currently being compared, flattened as [a0, b0, a1, b1, ...].
76
+ // Re-entering a pair means a cycle closed on both sides; assuming equality
77
+ // there is what makes equal cyclic graphs compare equal (bisimulation)
78
+ // while a differing leaf anywhere still reports a change.
79
+ //
80
+ // Scoped to the active path rather than every visited pair, so memory stays
81
+ // proportional to graph depth (single digits for loader payloads) instead of
82
+ // node count, and the common all-equal walk allocates one array.
83
+ //
84
+ // Tradeoff: a graph that shares one subtree through many nested parents
85
+ // re-walks it per path. Wire-serialized loader payloads are trees, so this
86
+ // does not arise; memoizing equal pairs instead measured ~2x slower on a
87
+ // realistic 400-item page, which is the case that actually runs per render.
88
+ const activePairs: object[] = []
89
+
90
+ function isSame(rawA: unknown, rawB: unknown): boolean {
91
+ if (Object.is(rawA, rawB)) {
92
+ return true
93
+ }
94
+ // `JSON.stringify` renders NaN/±Infinity as `null`; normalizing here keeps
95
+ // a non-finite number equal to the `null` a previous wire payload carried.
96
+ const a = toFiniteOrNull(rawA)
97
+ const b = toFiniteOrNull(rawB)
98
+ if (a === b) {
99
+ // Also accepts 0/-0, which `JSON.stringify` renders identically.
100
+ return true
101
+ }
102
+ if (!(isObjectLike(a) && isObjectLike(b))) {
103
+ return false
104
+ }
105
+ if (isThenable(a) || isThenable(b)) {
106
+ // Identity comparison above already failed: distinct async values.
107
+ return false
108
+ }
109
+ for (let index = 0; index < activePairs.length; index += 2) {
110
+ if (activePairs[index] === a && activePairs[index + 1] === b) {
111
+ return true
112
+ }
113
+ }
114
+
115
+ activePairs.push(a, b)
116
+ try {
117
+ return isSameOpenPair(a, b)
118
+ } finally {
119
+ activePairs.length -= 2
120
+ }
121
+ }
122
+
123
+ function isSameOpenPair(
124
+ a: Record<string, unknown>,
125
+ b: Record<string, unknown>
126
+ ): boolean {
127
+ const aJson = toJsonValue(a)
128
+ const bJson = toJsonValue(b)
129
+ if (aJson !== a || bJson !== b) {
130
+ return isSame(aJson, bJson)
131
+ }
132
+
133
+ if (Array.isArray(a) !== Array.isArray(b)) {
134
+ return false
135
+ }
136
+ if (Array.isArray(a) && Array.isArray(b)) {
137
+ return isSameArray(a, b)
138
+ }
139
+ if (!(isPlainObject(a) && isPlainObject(b))) {
140
+ // Opaque object with no JSON projection — identity is the only honest
141
+ // signal, and it already failed.
142
+ return false
143
+ }
144
+ return isSameObject(a, b)
145
+ }
146
+
147
+ function isSameArray(a: unknown[], b: unknown[]): boolean {
148
+ if (a.length !== b.length) {
149
+ return false
150
+ }
151
+ for (let index = 0; index < a.length; index++) {
152
+ const aItem = isJsonOmitted(a[index]) ? null : a[index]
153
+ const bItem = isJsonOmitted(b[index]) ? null : b[index]
154
+ if (!isSame(aItem, bItem)) {
155
+ return false
156
+ }
157
+ }
158
+ return true
159
+ }
160
+
161
+ function isSameObject(
162
+ a: Record<string, unknown>,
163
+ b: Record<string, unknown>
164
+ ): boolean {
165
+ let aKeyCount = 0
166
+ for (const key of Object.keys(a)) {
167
+ if (isJsonOmitted(a[key])) {
168
+ continue
169
+ }
170
+ aKeyCount++
171
+ // A JSON-visible value never compares equal to an omitted one, so a
172
+ // missing/undefined counterpart falls out of `isSame` as a change.
173
+ if (!isSame(a[key], b[key])) {
174
+ return false
175
+ }
176
+ }
177
+ let bKeyCount = 0
178
+ for (const key of Object.keys(b)) {
179
+ if (!isJsonOmitted(b[key])) {
180
+ bKeyCount++
181
+ }
182
+ }
183
+ return aKeyCount === bKeyCount
184
+ }
185
+
186
+ return isSame(left, right)
187
+ }
@@ -1,5 +1,6 @@
1
1
  import type { WeaverseHydrogenParams } from '../types'
2
2
  import type { WeaverseHydrogen } from '../WeaverseHydrogenRoot'
3
+ import { isSameLoaderPayload } from './is-same-loader-payload'
3
4
 
4
5
  /**
5
6
  * Apply a fresh loader payload to a reused Weaverse instance.
@@ -14,9 +15,11 @@ import type { WeaverseHydrogen } from '../WeaverseHydrogenRoot'
14
15
  * (unsaved drafts must not be clobbered) and applies updates via
15
16
  * `refreshStudio` instead.
16
17
  *
17
- * Change detection uses JSON comparison — loader payloads are
18
- * wire-serialized, so key order is stable between runs; a false mismatch
19
- * only costs one extra re-render.
18
+ * Change detection uses {@link isSameLoaderPayload}: a structural comparison
19
+ * that treats promises/thenables and other opaque objects as atomic values
20
+ * compared by identity, and tolerates cyclic graphs. `dataContext` carries
21
+ * live route loader data — including unresolved deferred promises whose React
22
+ * development metadata is cyclic — so it cannot be JSON-serialized here.
20
23
  */
21
24
  export function syncReusedInstance(
22
25
  weaverse: WeaverseHydrogen,
@@ -25,11 +28,11 @@ export function syncReusedInstance(
25
28
  weaverse.requestInfo = params.requestInfo
26
29
  weaverse.internal = params.internal
27
30
 
28
- let dataChanged =
29
- JSON.stringify(weaverse.data) !== JSON.stringify(params.data)
30
- let contextChanged =
31
- JSON.stringify(weaverse.dataContext ?? null) !==
32
- JSON.stringify(params.dataContext ?? null)
31
+ let dataChanged = !isSameLoaderPayload(weaverse.data, params.data)
32
+ let contextChanged = !isSameLoaderPayload(
33
+ weaverse.dataContext ?? null,
34
+ params.dataContext ?? null
35
+ )
33
36
 
34
37
  if (contextChanged) {
35
38
  // Assign before notifying any item store: components resolve data
@@ -9,11 +9,6 @@ import {
9
9
  import { useTranslation } from '../hooks/translation-context'
10
10
  import type { WeaverseHydrogen } from '../index'
11
11
  import { hasWeaverseStudio } from '../types'
12
- import {
13
- observeNavigation,
14
- registerPixelInstance,
15
- shouldFirePixel,
16
- } from './pixel'
17
12
  import { getStudioScriptSrc, resolveStudioScriptSrc } from './studio-script-src'
18
13
  import { useThemeSettingsStore } from './use-theme-settings-store'
19
14
 
@@ -133,36 +128,4 @@ export function useStudio(weaverse: WeaverseHydrogen) {
133
128
  .catch(console.error)
134
129
  }
135
130
  }, [pathname, search, navigation.state])
136
- usePixel(weaverse)
137
- }
138
-
139
- export function usePixel(context: WeaverseHydrogen) {
140
- let { projectId, pageId, isDesignMode, weaverseHost } = context
141
- let { key: navigationKey } = useLocation()
142
- // Observe every navigation so a persistent layout-level instance forgets
143
- // the previous navigation's fired pages on a real key change — but NOT on
144
- // an in-place remount that keeps the same history entry (e.g. the
145
- // `data={null}` suppress/restore path), which must not double-count.
146
- useEffect(() => {
147
- if (!(projectId && pageId) || isDesignMode) {
148
- return
149
- }
150
- observeNavigation(navigationKey)
151
- }, [navigationKey, projectId, pageId, isDesignMode])
152
-
153
- // biome-ignore lint/correctness/useExhaustiveDependencies: fire once on mount
154
- useEffect(() => {
155
- if (!(projectId && pageId) || isDesignMode) {
156
- return
157
- }
158
- // Register BEFORE deciding to fire: the navigation state must live at
159
- // least as long as some Weaverse instance is mounted (see pixel.ts).
160
- let unregister = registerPixelInstance()
161
- if (shouldFirePixel(navigationKey, pageId)) {
162
- let img = new Image()
163
- img.onload = () => img.remove()
164
- img.src = `${weaverseHost}/api/public/px?projectId=${projectId}&pageId=${pageId}`
165
- }
166
- return unregister
167
- }, [])
168
131
  }
@@ -1,16 +0,0 @@
1
- /**
2
- * Record the navigation key seen by a mounted instance. A change of key is a
3
- * real navigation and forgets the previous navigation's fired pages; an
4
- * unchanged key (an in-place remount / suppress-restore on the same history
5
- * entry) leaves them intact, so the page is not double-counted.
6
- */
7
- export declare function observeNavigation(navigationKey: string): void;
8
- export declare function shouldFirePixel(navigationKey: string, pageId: string): boolean;
9
- /**
10
- * Track a mounted Weaverse instance; returns the unmount cleanup. When the
11
- * last instance unmounts, the navigation state is forgotten so a Back that
12
- * restores the same history entry counts again even if no instance survived
13
- * to observe the detour.
14
- */
15
- export declare function registerPixelInstance(): () => void;
16
- //# sourceMappingURL=pixel.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"pixel.d.ts","sourceRoot":"","sources":["../../../src/utils/pixel.ts"],"names":[],"mappings":"AA8BA;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,aAAa,EAAE,MAAM,GAAG,IAAI,CAK7D;AAED,wBAAgB,eAAe,CAC7B,aAAa,EAAE,MAAM,EACrB,MAAM,EAAE,MAAM,GACb,OAAO,CAOT;AAED;;;;;GAKG;AACH,wBAAgB,qBAAqB,IAAI,MAAM,IAAI,CASlD"}
@@ -1,71 +0,0 @@
1
- // One pixel per page per navigation. A few facts shape the dedup:
2
- //
3
- // 1. Co-located instances: nested compositions mount multiple Weaverse
4
- // instances on one URL; each page is counted at most once.
5
- // 2. The pixel FIRE effect runs once per instance mount (see `usePixel`), so
6
- // an instance never re-fires on its own — a navigation is a fresh mount.
7
- // 3. `location.key` is stable per history entry. A back/forward POP restores
8
- // the entry's ORIGINAL key, and an in-place remount on the same entry
9
- // (e.g. the `data={null}` suppress/restore path in WeaverseHydrogenRoot)
10
- // keeps that same key. A REAL navigation is therefore a key CHANGE.
11
- //
12
- // `firedPageIds` is the set of pages already counted for the current
13
- // navigation. It is forgotten in two ways:
14
- // - On a key TRANSITION reported by ANY mounted instance via
15
- // `observeNavigation` (driven by a per-navigation effect). A persistent
16
- // layout-level instance keeps observing across a detour through a
17
- // non-Weaverse route (cart, search, account), so a Back to the original
18
- // entry is recognised as a new navigation and the page re-counts — while
19
- // an in-place suppress/restore (no key change) does NOT re-count.
20
- // - When the LAST instance unmounts (`registerPixelInstance`), covering the
21
- // detour case where no instance survives to observe the key round-trip
22
- // (a page with no persistent layout going to /cart and back).
23
- //
24
- // Known tradeoff: React StrictMode's dev-only mount→cleanup→mount cycle
25
- // resets state and double-fires in development — parity with the pre-dedupe
26
- // behavior; production semantics are unaffected.
27
- let currentNavigationKey: string | null = null
28
- let firedPageIds = new Set<string>()
29
- let mountedInstances = 0
30
-
31
- /**
32
- * Record the navigation key seen by a mounted instance. A change of key is a
33
- * real navigation and forgets the previous navigation's fired pages; an
34
- * unchanged key (an in-place remount / suppress-restore on the same history
35
- * entry) leaves them intact, so the page is not double-counted.
36
- */
37
- export function observeNavigation(navigationKey: string): void {
38
- if (navigationKey !== currentNavigationKey) {
39
- currentNavigationKey = navigationKey
40
- firedPageIds.clear()
41
- }
42
- }
43
-
44
- export function shouldFirePixel(
45
- navigationKey: string,
46
- pageId: string
47
- ): boolean {
48
- observeNavigation(navigationKey)
49
- if (firedPageIds.has(pageId)) {
50
- return false
51
- }
52
- firedPageIds.add(pageId)
53
- return true
54
- }
55
-
56
- /**
57
- * Track a mounted Weaverse instance; returns the unmount cleanup. When the
58
- * last instance unmounts, the navigation state is forgotten so a Back that
59
- * restores the same history entry counts again even if no instance survived
60
- * to observe the detour.
61
- */
62
- export function registerPixelInstance(): () => void {
63
- mountedInstances += 1
64
- return () => {
65
- mountedInstances -= 1
66
- if (mountedInstances === 0) {
67
- currentNavigationKey = null
68
- firedPageIds.clear()
69
- }
70
- }
71
- }