@granite-js/react-native 1.0.35 → 1.0.36

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # @granite-js/react-native
2
2
 
3
+ ## 1.0.36
4
+
5
+ ### Patch Changes
6
+
7
+ - 9336784: fix(react-native): correct ImpressionArea/InView visibility in nested scroll views
8
+ - @granite-js/blur-view@1.0.36
9
+ - @granite-js/brownfield-module@1.0.36
10
+ - @granite-js/cli@1.0.36
11
+ - @granite-js/jest@1.0.36
12
+ - @granite-js/mpack@1.0.36
13
+ - @granite-js/native@1.0.36
14
+ - @granite-js/plugin-core@1.0.36
15
+ - @granite-js/style-utils@1.0.36
16
+ - @granite-js/video@1.0.36
17
+
3
18
  ## 1.0.35
4
19
 
5
20
  ### Patch Changes
@@ -1,6 +1,13 @@
1
1
  import IOManager from './IOManager';
2
2
  export interface IOContextValue {
3
3
  manager: null | IOManager;
4
+ /**
5
+ * The enclosing `IOContext` value, if this scroll container is nested inside another
6
+ * `IOScrollView`/`IOFlatList`. This forms a linked list from the innermost scroll
7
+ * container up to the outermost one, allowing `InView` to observe every ancestor
8
+ * viewport (like the web `IntersectionObserver`, which clips against all ancestors).
9
+ */
10
+ parent?: IOContextValue | null;
4
11
  }
5
12
  /**
6
13
  * @name IOContext
@@ -1,8 +1,20 @@
1
1
  import { ComponentType, PureComponent, ReactElement, ReactNode, RefObject } from 'react';
2
2
  import { LayoutChangeEvent, View, ViewProps } from 'react-native';
3
3
  import { IOContextValue } from './IOContext';
4
- import { ObserverInstance } from './IOManager';
4
+ import IOManager from './IOManager';
5
5
  import { Element } from './IntersectionObserver';
6
+ /**
7
+ * Per-manager observation state. A single `InView` can be observed by multiple
8
+ * `IOManager`s at once (one per ancestor scroll container). Each binding keeps its
9
+ * own `Element` because every observer measures the view against a different root
10
+ * and writes its own `layout`/`inView`/`intersectionRatio`.
11
+ */
12
+ interface ManagerBinding {
13
+ manager: IOManager;
14
+ element: Element;
15
+ inView: boolean;
16
+ intersectionRatio: number;
17
+ }
6
18
  export interface RenderProps {
7
19
  inView: boolean;
8
20
  onChange: (inView: boolean) => void;
@@ -87,12 +99,26 @@ declare class InView<T = ViewProps> extends PureComponent<InViewProps<T>> {
87
99
  static defaultProps: Partial<InViewProps>;
88
100
  context: undefined | IOContextValue;
89
101
  mounted: boolean;
90
- protected element: Element;
91
- protected instance: undefined | ObserverInstance;
102
+ protected bindings: ManagerBinding[];
103
+ protected combinedInView: boolean;
104
+ protected combinedRatio: number;
105
+ protected lastWidth: number;
106
+ protected lastHeight: number;
92
107
  protected view: any;
93
- constructor(props: InViewProps<T>);
94
108
  componentDidMount(): void;
95
109
  componentWillUnmount(): void;
110
+ /**
111
+ * Walks the `IOContext` chain from the nearest scroll container to the outermost,
112
+ * collecting every non-null manager along the way.
113
+ */
114
+ protected collectManagers(): IOManager[];
115
+ protected handleBindingChange: (binding: ManagerBinding, inView: boolean, intersectionRatio: number) => void;
116
+ /**
117
+ * Recomputes the combined visibility across all ancestor viewports and notifies
118
+ * `onChange` only when the AND-ed result actually changes. `intersectionRatio` is
119
+ * reported as the smallest ratio among ancestors — the most restrictive viewport.
120
+ */
121
+ protected updateCombined(): void;
96
122
  protected handleChange: (inView: boolean, areaThreshold: number) => void;
97
123
  protected handleRef: (ref: any) => void;
98
124
  protected handleLayout: (event: LayoutChangeEvent) => void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@granite-js/react-native",
3
- "version": "1.0.35",
3
+ "version": "1.0.36",
4
4
  "description": "The Granite Framework",
5
5
  "bin": {
6
6
  "granite": "./bin/cli.js"
@@ -91,8 +91,8 @@
91
91
  "@babel/core": "7.28.5",
92
92
  "@babel/preset-env": "7.28.5",
93
93
  "@babel/preset-typescript": "7.28.5",
94
- "@granite-js/native": "1.0.35",
95
- "@granite-js/vitest": "1.0.35",
94
+ "@granite-js/native": "1.0.36",
95
+ "@granite-js/vitest": "1.0.36",
96
96
  "@testing-library/dom": "^10.4.0",
97
97
  "@testing-library/react": "^16.1.0",
98
98
  "@types/babel__core": "^7",
@@ -114,21 +114,21 @@
114
114
  "zod": "^4.1.12"
115
115
  },
116
116
  "peerDependencies": {
117
- "@granite-js/native": "1.0.35",
117
+ "@granite-js/native": "1.0.36",
118
118
  "@types/react": "*",
119
119
  "brick-module": "*",
120
120
  "react": "*",
121
121
  "react-native": "*"
122
122
  },
123
123
  "dependencies": {
124
- "@granite-js/blur-view": "1.0.35",
125
- "@granite-js/brownfield-module": "1.0.35",
126
- "@granite-js/cli": "1.0.35",
127
- "@granite-js/jest": "1.0.35",
128
- "@granite-js/mpack": "1.0.35",
129
- "@granite-js/plugin-core": "1.0.35",
130
- "@granite-js/style-utils": "1.0.35",
131
- "@granite-js/video": "1.0.35",
124
+ "@granite-js/blur-view": "1.0.36",
125
+ "@granite-js/brownfield-module": "1.0.36",
126
+ "@granite-js/cli": "1.0.36",
127
+ "@granite-js/jest": "1.0.36",
128
+ "@granite-js/mpack": "1.0.36",
129
+ "@granite-js/plugin-core": "1.0.36",
130
+ "@granite-js/style-utils": "1.0.36",
131
+ "@granite-js/video": "1.0.36",
132
132
  "@standard-schema/spec": "^1.0.0",
133
133
  "es-toolkit": "^1.39.8",
134
134
  "react-native-url-polyfill": "3.0.0"
@@ -3,6 +3,13 @@ import IOManager from './IOManager';
3
3
 
4
4
  export interface IOContextValue {
5
5
  manager: null | IOManager;
6
+ /**
7
+ * The enclosing `IOContext` value, if this scroll container is nested inside another
8
+ * `IOScrollView`/`IOFlatList`. This forms a linked list from the innermost scroll
9
+ * container up to the outermost one, allowing `InView` to observe every ancestor
10
+ * viewport (like the web `IntersectionObserver`, which clips against all ancestors).
11
+ */
12
+ parent?: IOContextValue | null;
6
13
  }
7
14
 
8
15
  /**
@@ -11,6 +18,7 @@ export interface IOContextValue {
11
18
  */
12
19
  const IOContext = createContext<IOContextValue>({
13
20
  manager: null,
21
+ parent: null,
14
22
  });
15
23
 
16
24
  export default IOContext;
@@ -0,0 +1,174 @@
1
+ import { render } from '@testing-library/react';
2
+ import { View } from 'react-native';
3
+ import { describe, expect, it, vi } from 'vitest';
4
+ import IOContext, { IOContextValue } from './IOContext';
5
+ import IOManager, { ObserverInstance, ObserverInstanceCallback } from './IOManager';
6
+ import InView from './InView';
7
+ import { Element } from './IntersectionObserver';
8
+
9
+ /**
10
+ * A stand-in for `IOManager` that captures the observed element/callback so the test
11
+ * can drive intersection changes directly, without a real native scroll viewport.
12
+ */
13
+ class FakeIntersectionObserverRegistry {
14
+ observe = vi.fn((element: Element, callback: ObserverInstanceCallback): ObserverInstance => {
15
+ this.callbacks.set(element, callback);
16
+ return { callback, element, observerId: 1, observer: null as never };
17
+ });
18
+
19
+ unobserve = vi.fn((element: Element) => {
20
+ this.callbacks.delete(element);
21
+ });
22
+
23
+ private callbacks = new Map<Element, ObserverInstanceCallback>();
24
+
25
+ /** Emits an intersection change to every element this manager observes. */
26
+ emit(inView: boolean, intersectionRatio: number) {
27
+ for (const callback of this.callbacks.values()) {
28
+ callback(inView, intersectionRatio);
29
+ }
30
+ }
31
+
32
+ get asManager(): IOManager {
33
+ return this as unknown as IOManager;
34
+ }
35
+ }
36
+
37
+ function renderInView(contextValue: IOContextValue, onChange: (inView: boolean, ratio: number) => void) {
38
+ return render(
39
+ <IOContext.Provider value={contextValue}>
40
+ <InView onChange={onChange}>
41
+ <View />
42
+ </InView>
43
+ </IOContext.Provider>
44
+ );
45
+ }
46
+
47
+ describe('InView', () => {
48
+ it('observes the single ancestor manager and forwards its intersection changes', () => {
49
+ const manager = new FakeIntersectionObserverRegistry();
50
+ const onChange = vi.fn();
51
+
52
+ renderInView({ manager: manager.asManager, parent: { manager: null } }, onChange);
53
+
54
+ expect(manager.observe).toHaveBeenCalledTimes(1);
55
+
56
+ manager.emit(true, 0.3);
57
+
58
+ expect(onChange).toHaveBeenCalledWith(true, 0.3);
59
+ });
60
+
61
+ it('observes every ancestor viewport when scroll containers are nested', () => {
62
+ const inner = new FakeIntersectionObserverRegistry();
63
+ const outer = new FakeIntersectionObserverRegistry();
64
+ const onChange = vi.fn();
65
+
66
+ renderInView(
67
+ {
68
+ manager: inner.asManager,
69
+ parent: { manager: outer.asManager, parent: { manager: null } },
70
+ },
71
+ onChange
72
+ );
73
+
74
+ expect(inner.observe).toHaveBeenCalledTimes(1);
75
+ expect(outer.observe).toHaveBeenCalledTimes(1);
76
+ });
77
+
78
+ it('reports visible only when the element intersects ALL ancestor viewports (AND)', () => {
79
+ const inner = new FakeIntersectionObserverRegistry();
80
+ const outer = new FakeIntersectionObserverRegistry();
81
+ const onChange = vi.fn();
82
+
83
+ renderInView(
84
+ {
85
+ manager: inner.asManager,
86
+ parent: { manager: outer.asManager, parent: { manager: null } },
87
+ },
88
+ onChange
89
+ );
90
+
91
+ // Visible in the inner (horizontal) viewport, but the outer (vertical) viewport
92
+ // hasn't scrolled it into view yet -> must NOT be considered visible.
93
+ inner.emit(true, 0.5);
94
+ expect(onChange).not.toHaveBeenCalled();
95
+
96
+ // Now visible in the outer viewport too -> visible, with the most restrictive
97
+ // (smallest) ratio reported.
98
+ outer.emit(true, 1);
99
+ expect(onChange).toHaveBeenCalledExactlyOnceWith(true, 0.5);
100
+
101
+ // Scrolling the outer viewport away hides it again even though the inner viewport
102
+ // still reports it visible.
103
+ onChange.mockClear();
104
+ outer.emit(false, 0);
105
+ expect(onChange).toHaveBeenCalledExactlyOnceWith(false, 0);
106
+ });
107
+
108
+ it('does not re-notify when the combined visibility is unchanged', () => {
109
+ const inner = new FakeIntersectionObserverRegistry();
110
+ const outer = new FakeIntersectionObserverRegistry();
111
+ const onChange = vi.fn();
112
+
113
+ renderInView(
114
+ {
115
+ manager: inner.asManager,
116
+ parent: { manager: outer.asManager, parent: { manager: null } },
117
+ },
118
+ onChange
119
+ );
120
+
121
+ inner.emit(true, 0.5);
122
+ outer.emit(true, 1);
123
+ onChange.mockClear();
124
+
125
+ // The outer ratio changes but the inner viewport stays the more restrictive one,
126
+ // so the combined result (visible, ratio 0.5) is unchanged.
127
+ outer.emit(true, 0.8);
128
+ expect(onChange).not.toHaveBeenCalled();
129
+ });
130
+
131
+ it('unobserves every ancestor manager on unmount', () => {
132
+ const inner = new FakeIntersectionObserverRegistry();
133
+ const outer = new FakeIntersectionObserverRegistry();
134
+ const onChange = vi.fn();
135
+
136
+ const { unmount } = renderInView(
137
+ {
138
+ manager: inner.asManager,
139
+ parent: { manager: outer.asManager, parent: { manager: null } },
140
+ },
141
+ onChange
142
+ );
143
+
144
+ unmount();
145
+
146
+ expect(inner.unobserve).toHaveBeenCalledTimes(1);
147
+ expect(outer.unobserve).toHaveBeenCalledTimes(1);
148
+ });
149
+
150
+ it('with triggerOnce, stops observing all ancestors once visible', () => {
151
+ const inner = new FakeIntersectionObserverRegistry();
152
+ const outer = new FakeIntersectionObserverRegistry();
153
+ const onChange = vi.fn();
154
+
155
+ render(
156
+ <IOContext.Provider
157
+ value={{
158
+ manager: inner.asManager,
159
+ parent: { manager: outer.asManager, parent: { manager: null } },
160
+ }}
161
+ >
162
+ <InView triggerOnce onChange={onChange}>
163
+ <View />
164
+ </InView>
165
+ </IOContext.Provider>
166
+ );
167
+
168
+ inner.emit(true, 0.5);
169
+ outer.emit(true, 1);
170
+
171
+ expect(inner.unobserve).toHaveBeenCalledTimes(1);
172
+ expect(outer.unobserve).toHaveBeenCalledTimes(1);
173
+ });
174
+ });
@@ -1,9 +1,22 @@
1
1
  import { ComponentType, PureComponent, ReactElement, ReactNode, RefObject } from 'react';
2
2
  import { LayoutChangeEvent, View, ViewProps } from 'react-native';
3
3
  import IOContext, { IOContextValue } from './IOContext';
4
- import { ObserverInstance } from './IOManager';
4
+ import IOManager from './IOManager';
5
5
  import { Element } from './IntersectionObserver';
6
6
 
7
+ /**
8
+ * Per-manager observation state. A single `InView` can be observed by multiple
9
+ * `IOManager`s at once (one per ancestor scroll container). Each binding keeps its
10
+ * own `Element` because every observer measures the view against a different root
11
+ * and writes its own `layout`/`inView`/`intersectionRatio`.
12
+ */
13
+ interface ManagerBinding {
14
+ manager: IOManager;
15
+ element: Element;
16
+ inView: boolean;
17
+ intersectionRatio: number;
18
+ }
19
+
7
20
  export interface RenderProps {
8
21
  inView: boolean;
9
22
  onChange: (inView: boolean) => void;
@@ -97,46 +110,98 @@ class InView<T = ViewProps> extends PureComponent<InViewProps<T>> {
97
110
  context: undefined | IOContextValue = undefined;
98
111
  mounted = false;
99
112
 
100
- protected element: Element;
101
- protected instance: undefined | ObserverInstance;
113
+ protected bindings: ManagerBinding[] = [];
114
+ protected combinedInView = false;
115
+ protected combinedRatio = 0;
116
+ protected lastWidth = 0;
117
+ protected lastHeight = 0;
102
118
  protected view: any;
103
119
 
104
- constructor(props: InViewProps<T>) {
105
- super(props);
106
-
107
- this.element = {
108
- inView: false,
109
- intersectionRatio: 0,
110
- layout: {
111
- x: 0,
112
- y: 0,
113
- width: 0,
114
- height: 0,
115
- },
116
- measureLayout: this.measureLayout,
117
- };
118
- }
119
-
120
120
  componentDidMount() {
121
121
  this.mounted = true;
122
- if (this.context?.manager) {
123
- this.instance = this.context.manager.observe(this.element, this.handleChange);
124
- }
122
+ // Observe the element in every ancestor viewport, from the innermost scroll
123
+ // container up to the outermost. The element is considered visible only when it
124
+ // intersects *all* of them (AND), matching the web `IntersectionObserver`, which
125
+ // clips the target against every ancestor's scrollport.
126
+ this.bindings = this.collectManagers().map((manager) => {
127
+ const binding: ManagerBinding = {
128
+ manager,
129
+ inView: false,
130
+ intersectionRatio: 0,
131
+ element: {
132
+ inView: false,
133
+ intersectionRatio: 0,
134
+ layout: { x: 0, y: 0, width: 0, height: 0 },
135
+ measureLayout: this.measureLayout,
136
+ },
137
+ };
138
+ manager.observe(binding.element, (inView, intersectionRatio) =>
139
+ this.handleBindingChange(binding, inView, intersectionRatio)
140
+ );
141
+ return binding;
142
+ });
125
143
  }
126
144
 
127
145
  componentWillUnmount() {
128
146
  this.mounted = false;
129
- if (this.context?.manager && this.instance) {
130
- this.context.manager.unobserve(this.element);
147
+ for (const binding of this.bindings) {
148
+ binding.manager.unobserve(binding.element);
131
149
  }
150
+ this.bindings = [];
151
+ }
152
+
153
+ /**
154
+ * Walks the `IOContext` chain from the nearest scroll container to the outermost,
155
+ * collecting every non-null manager along the way.
156
+ */
157
+ protected collectManagers(): IOManager[] {
158
+ const managers: IOManager[] = [];
159
+ let context: IOContextValue | null | undefined = this.context;
160
+ while (context != null) {
161
+ if (context.manager != null) {
162
+ managers.push(context.manager);
163
+ }
164
+ context = context.parent;
165
+ }
166
+ return managers;
167
+ }
168
+
169
+ protected handleBindingChange = (binding: ManagerBinding, inView: boolean, intersectionRatio: number) => {
170
+ binding.inView = inView;
171
+ binding.intersectionRatio = intersectionRatio;
172
+ this.updateCombined();
173
+ };
174
+
175
+ /**
176
+ * Recomputes the combined visibility across all ancestor viewports and notifies
177
+ * `onChange` only when the AND-ed result actually changes. `intersectionRatio` is
178
+ * reported as the smallest ratio among ancestors — the most restrictive viewport.
179
+ */
180
+ protected updateCombined() {
181
+ if (!this.mounted || this.bindings.length === 0) {
182
+ return;
183
+ }
184
+
185
+ const inView = this.bindings.every((binding) => binding.inView);
186
+ const intersectionRatio = inView
187
+ ? this.bindings.reduce((min, binding) => Math.min(min, binding.intersectionRatio), 1)
188
+ : 0;
189
+
190
+ if (inView === this.combinedInView && intersectionRatio === this.combinedRatio) {
191
+ return;
192
+ }
193
+
194
+ this.combinedInView = inView;
195
+ this.combinedRatio = intersectionRatio;
196
+ this.handleChange(inView, intersectionRatio);
132
197
  }
133
198
 
134
199
  protected handleChange = (inView: boolean, areaThreshold: number) => {
135
200
  if (this.mounted) {
136
201
  const { triggerOnce, onChange } = this.props;
137
202
  if (inView && triggerOnce) {
138
- if (this.context?.manager) {
139
- this.context?.manager.unobserve(this.element);
203
+ for (const binding of this.bindings) {
204
+ binding.manager.unobserve(binding.element);
140
205
  }
141
206
  }
142
207
  if (onChange) {
@@ -153,9 +218,12 @@ class InView<T = ViewProps> extends PureComponent<InViewProps<T>> {
153
218
  const {
154
219
  nativeEvent: { layout },
155
220
  } = event;
156
- if (layout.width !== this.element.layout.width || layout.height !== this.element.layout.height) {
157
- if (this.element.onLayout) {
158
- this.element.onLayout();
221
+ if (layout.width !== this.lastWidth || layout.height !== this.lastHeight) {
222
+ this.lastWidth = layout.width;
223
+ this.lastHeight = layout.height;
224
+ // Re-measure the target in every ancestor viewport when its size changes.
225
+ for (const binding of this.bindings) {
226
+ binding.element.onLayout?.();
159
227
  }
160
228
  }
161
229
  const { onLayout } = this.props;
@@ -0,0 +1,89 @@
1
+ import { NativeScrollEvent } from 'react-native';
2
+ import { describe, expect, it } from 'vitest';
3
+ import IntersectionObserver, { Element, IntersectionObserverEntry, Root } from './IntersectionObserver';
4
+
5
+ const VIEWPORT = 300;
6
+
7
+ function createRoot(horizontal: boolean, contentOffset: { x: number; y: number }): Root {
8
+ return {
9
+ node: {},
10
+ horizontal,
11
+ current: {
12
+ contentOffset,
13
+ contentSize: { width: 1000, height: 1000 },
14
+ layoutMeasurement: { width: VIEWPORT, height: VIEWPORT },
15
+ } as NativeScrollEvent,
16
+ };
17
+ }
18
+
19
+ /**
20
+ * Creates an observed element positioned at a fixed layout. `measureLayout`
21
+ * synchronously reports that layout so we can drive the observer without a native
22
+ * scroll view.
23
+ */
24
+ function createElement(layout: { x: number; y: number; width: number; height: number }): Element {
25
+ return {
26
+ inView: false,
27
+ intersectionRatio: 0,
28
+ layout: { x: 0, y: 0, width: 0, height: 0 },
29
+ measureLayout: (_node, callback) => callback(layout.x, layout.y, layout.width, layout.height),
30
+ };
31
+ }
32
+
33
+ /**
34
+ * Observes the element, forces a measure + intersection computation, and returns the
35
+ * flattened entries the observer emitted.
36
+ */
37
+ function measure(root: Root, element: Element): IntersectionObserverEntry[] {
38
+ const entries: IntersectionObserverEntry[] = [];
39
+ const observer = new IntersectionObserver((changed) => entries.push(...changed), { root });
40
+ observer.observe(element);
41
+ // Synchronously trigger measureTarget -> onScrollHandler (throttle leading edge).
42
+ root.onLayout?.();
43
+ return entries;
44
+ }
45
+
46
+ describe('IntersectionObserver', () => {
47
+ it('does not mark a horizontally adjacent target (0px visible) as intersecting', () => {
48
+ const root = createRoot(true, { x: 0, y: 0 });
49
+ // Target's left edge sits exactly on the viewport's right edge.
50
+ const element = createElement({ x: VIEWPORT, y: 0, width: VIEWPORT, height: 200 });
51
+
52
+ const entries = measure(root, element);
53
+
54
+ expect(entries.some((entry) => entry.isIntersecting)).toBe(false);
55
+ expect(element.inView).toBe(false);
56
+ });
57
+
58
+ it('does not mark a vertically adjacent target (0px visible) as intersecting', () => {
59
+ const root = createRoot(false, { x: 0, y: 0 });
60
+ // Target's top edge sits exactly on the viewport's bottom edge.
61
+ const element = createElement({ x: 0, y: VIEWPORT, width: 200, height: VIEWPORT });
62
+
63
+ const entries = measure(root, element);
64
+
65
+ expect(entries.some((entry) => entry.isIntersecting)).toBe(false);
66
+ expect(element.inView).toBe(false);
67
+ });
68
+
69
+ it('marks a target as intersecting once even 1px is visible', () => {
70
+ const root = createRoot(true, { x: 0, y: 0 });
71
+ // Target overlaps the viewport by 1px on its left edge.
72
+ const element = createElement({ x: VIEWPORT - 1, y: 0, width: VIEWPORT, height: 200 });
73
+
74
+ const entries = measure(root, element);
75
+
76
+ expect(entries.some((entry) => entry.isIntersecting)).toBe(true);
77
+ expect(element.inView).toBe(true);
78
+ });
79
+
80
+ it('reports a fully visible target with ratio 1', () => {
81
+ const root = createRoot(true, { x: 0, y: 0 });
82
+ const element = createElement({ x: 0, y: 0, width: VIEWPORT, height: 200 });
83
+
84
+ const entries = measure(root, element);
85
+
86
+ const intersecting = entries.find((entry) => entry.isIntersecting);
87
+ expect(intersecting?.intersectionRatio).toBe(1);
88
+ });
89
+ });
@@ -165,9 +165,12 @@ class IntersectionObserver {
165
165
  const visibleWidth = Math.max(visibleTargetMaxX - visibleTargetMinX, 0);
166
166
 
167
167
  intersectionRatio = visibleWidth / targetLayout.width;
168
- isIntersecting =
169
- contentOffsetWithLayout + (rootMargin.right || 0) >= targetLayout.x &&
170
- contentOffset.x - (rootMargin.left || 0) <= targetLayout.x + targetLayout.width;
168
+ // Intersecting only when there is a real overlap. A target whose edge
169
+ // exactly touches the viewport (0px visible) is NOT considered visible,
170
+ // matching the web IntersectionObserver (a zero-area intersection is not
171
+ // intersecting). Using `>=` here would fire impressions for elements that
172
+ // are merely adjacent to the viewport edge.
173
+ isIntersecting = visibleWidth > 0;
171
174
  } else {
172
175
  const visibleTargetMinY = Math.max(contentOffset.y - (rootMargin.top || 0), targetLayout.y);
173
176
  const visibleTargetMaxY = Math.min(
@@ -177,9 +180,9 @@ class IntersectionObserver {
177
180
  const visibleHeight = Math.max(visibleTargetMaxY - visibleTargetMinY, 0);
178
181
 
179
182
  intersectionRatio = visibleHeight / targetLayout.height;
180
- isIntersecting =
181
- contentOffsetWithLayout + (rootMargin.bottom || 0) >= targetLayout.y &&
182
- contentOffset.y - (rootMargin.top || 0) <= targetLayout.y + targetLayout.height;
183
+ // See the horizontal branch above: 0px overlap (edge exactly touching the
184
+ // viewport) is not intersecting.
185
+ isIntersecting = visibleHeight > 0;
183
186
  }
184
187
 
185
188
  intersectionRatio = Math.floor(intersectionRatio * 100) / 100;
@@ -32,13 +32,18 @@ function withIO<
32
32
  ) {
33
33
  type ScrollableComponentProps = CompProps & IOComponentProps;
34
34
  const IOScrollableComponent = class extends PureComponent<ScrollableComponentProps> {
35
+ // Consume the enclosing IOContext so nested scroll containers can chain their
36
+ // managers. `contextType` makes the parent context available as the second
37
+ // constructor argument.
38
+ static contextType = IOContext;
39
+
35
40
  protected node: any;
36
41
  protected scroller: RefObject<any>;
37
42
  protected root: Root;
38
43
  protected manager: IOManager;
39
44
  protected contextValue: IOContextValue;
40
45
 
41
- constructor(props: ScrollableComponentProps) {
46
+ constructor(props: ScrollableComponentProps, parentContext?: IOContextValue) {
42
47
  super(props);
43
48
 
44
49
  // eslint-disable-next-line @typescript-eslint/no-this-alias
@@ -84,6 +89,9 @@ function withIO<
84
89
  this.manager = manager;
85
90
  this.contextValue = {
86
91
  manager,
92
+ // Link to the enclosing scroll container (if any) so descendant `InView`
93
+ // components can compute intersection against every ancestor viewport.
94
+ parent: parentContext ?? null,
87
95
  };
88
96
  }
89
97