@symbiote-native/angular 0.7.0 → 1.0.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.
- package/README.md +3 -3
- package/babel-lower-host-primitives.cjs +636 -0
- package/babel-register-composed.cjs +21 -3
- package/build/angular/anchor-host-registry.js +3 -1
- package/build/angular/anchor-host-registry.js.map +1 -1
- package/build/angular/bootstrap.js +1 -1
- package/build/angular/bootstrap.js.map +1 -1
- package/build/angular/components/activity-indicator/index.android.js +7 -2
- package/build/angular/components/activity-indicator/index.android.js.map +1 -1
- package/build/angular/components/activity-indicator/index.ios.js +3 -1
- package/build/angular/components/activity-indicator/index.ios.js.map +1 -1
- package/build/angular/components/activity-indicator/shared.d.ts +1 -1
- package/build/angular/components/activity-indicator/shared.js +1 -1
- package/build/angular/components/activity-indicator/shared.js.map +1 -1
- package/build/angular/components/button.d.ts +3 -0
- package/build/angular/components/button.js +22 -5
- package/build/angular/components/button.js.map +1 -1
- package/build/angular/components/flat-list/index.d.ts +1 -1
- package/build/angular/components/flat-list/index.js +47 -14
- package/build/angular/components/flat-list/index.js.map +1 -1
- package/build/angular/components/image/index.android.js +4 -10
- package/build/angular/components/image/index.android.js.map +1 -1
- package/build/angular/components/image/index.ios.js +4 -10
- package/build/angular/components/image/index.ios.js.map +1 -1
- package/build/angular/components/image/shared.d.ts +4 -4
- package/build/angular/components/image/shared.js +51 -33
- package/build/angular/components/image/shared.js.map +1 -1
- package/build/angular/components/image-background.d.ts +3 -0
- package/build/angular/components/image-background.js +32 -9
- package/build/angular/components/image-background.js.map +1 -1
- package/build/angular/components/input-accessory-view/index.d.ts +2 -1
- package/build/angular/components/input-accessory-view/index.js +18 -16
- package/build/angular/components/input-accessory-view/index.js.map +1 -1
- package/build/angular/components/keyboard-avoiding-view/index.d.ts +3 -1
- package/build/angular/components/keyboard-avoiding-view/index.js +56 -19
- package/build/angular/components/keyboard-avoiding-view/index.js.map +1 -1
- package/build/angular/components/modal/index.d.ts +1 -0
- package/build/angular/components/modal/index.js +21 -10
- package/build/angular/components/modal/index.js.map +1 -1
- package/build/angular/components/pressable/index.d.ts +10 -5
- package/build/angular/components/pressable/index.js +61 -16
- package/build/angular/components/pressable/index.js.map +1 -1
- package/build/angular/components/refresh-control/index.d.ts +2 -1
- package/build/angular/components/refresh-control/index.js +13 -9
- package/build/angular/components/refresh-control/index.js.map +1 -1
- package/build/angular/components/safe-area-view/index.d.ts +3 -1
- package/build/angular/components/safe-area-view/index.js +22 -20
- package/build/angular/components/safe-area-view/index.js.map +1 -1
- package/build/angular/components/scroll-view/index.android.js +44 -14
- package/build/angular/components/scroll-view/index.android.js.map +1 -1
- package/build/angular/components/scroll-view/index.ios.js +13 -5
- package/build/angular/components/scroll-view/index.ios.js.map +1 -1
- package/build/angular/components/scroll-view/projection.d.ts +32 -0
- package/build/angular/components/scroll-view/projection.js +212 -47
- package/build/angular/components/scroll-view/projection.js.map +1 -1
- package/build/angular/components/scroll-view/shared.d.ts +2 -0
- package/build/angular/components/scroll-view/shared.js +48 -11
- package/build/angular/components/scroll-view/shared.js.map +1 -1
- package/build/angular/components/scroll-view/sticky-header.js +7 -3
- package/build/angular/components/scroll-view/sticky-header.js.map +1 -1
- package/build/angular/components/section-list/index.d.ts +9 -1
- package/build/angular/components/section-list/index.js +72 -18
- package/build/angular/components/section-list/index.js.map +1 -1
- package/build/angular/components/switch/index.android.js +23 -9
- package/build/angular/components/switch/index.android.js.map +1 -1
- package/build/angular/components/switch/index.ios.js +23 -9
- package/build/angular/components/switch/index.ios.js.map +1 -1
- package/build/angular/components/switch/shared.d.ts +1 -1
- package/build/angular/components/switch/shared.js +5 -2
- package/build/angular/components/switch/shared.js.map +1 -1
- package/build/angular/components/text-input.d.ts +8 -2
- package/build/angular/components/text-input.js +79 -34
- package/build/angular/components/text-input.js.map +1 -1
- package/build/angular/components/touchable/index.d.ts +36 -7
- package/build/angular/components/touchable/index.js +280 -72
- package/build/angular/components/touchable/index.js.map +1 -1
- package/build/angular/components/touchable-native-feedback/index.d.ts +2 -1
- package/build/angular/components/touchable-native-feedback/index.js +19 -17
- package/build/angular/components/touchable-native-feedback/index.js.map +1 -1
- package/build/angular/components/virtualized-list/directives.js +4 -0
- package/build/angular/components/virtualized-list/directives.js.map +1 -1
- package/build/angular/components/virtualized-list/index.d.ts +8 -2
- package/build/angular/components/virtualized-list/index.js +207 -61
- package/build/angular/components/virtualized-list/index.js.map +1 -1
- package/build/angular/components/virtualized-section-list/directives.js.map +1 -1
- package/build/angular/components/virtualized-section-list/index.d.ts +21 -1
- package/build/angular/components/virtualized-section-list/index.js +100 -22
- package/build/angular/components/virtualized-section-list/index.js.map +1 -1
- package/build/angular/components.d.ts +2 -2
- package/build/angular/components.js +1 -1
- package/build/angular/components.js.map +1 -1
- package/build/angular/create-portal/index.d.ts +11 -2
- package/build/angular/create-portal/index.js +42 -12
- package/build/angular/create-portal/index.js.map +1 -1
- package/build/angular/create-tunnel/index.js.map +1 -1
- package/build/angular/descriptor-to-angular/index.js +3 -1
- package/build/angular/descriptor-to-angular/index.js.map +1 -1
- package/build/angular/diagnostics.d.ts +33 -0
- package/build/angular/diagnostics.js +82 -0
- package/build/angular/diagnostics.js.map +1 -0
- package/build/angular/gate-demand.d.ts +37 -0
- package/build/angular/gate-demand.js +84 -0
- package/build/angular/gate-demand.js.map +1 -0
- package/build/angular/host-instance/index.js +1 -1
- package/build/angular/host-instance/index.js.map +1 -1
- package/build/angular/index.d.ts +6 -2
- package/build/angular/index.js +16 -1
- package/build/angular/index.js.map +1 -1
- package/build/angular/modules/animated/create-animated-component.d.ts +1 -0
- package/build/angular/modules/animated/create-animated-component.js +41 -18
- package/build/angular/modules/animated/create-animated-component.js.map +1 -1
- package/build/angular/modules/app-registry/index.js.map +1 -1
- package/build/angular/modules/status-bar/index.js +1 -1
- package/build/angular/modules/status-bar/index.js.map +1 -1
- package/build/angular/primitives/index.d.ts +31 -2
- package/build/angular/primitives/index.js +81 -3
- package/build/angular/primitives/index.js.map +1 -1
- package/build/angular/primitives/shared.d.ts +9 -1
- package/build/angular/primitives/shared.js +49 -3
- package/build/angular/primitives/shared.js.map +1 -1
- package/build/angular/register.d.ts +1 -0
- package/build/angular/register.js +35 -0
- package/build/angular/register.js.map +1 -0
- package/build/angular/render/index.js +51 -6
- package/build/angular/render/index.js.map +1 -1
- package/build/angular/renderer/index.d.ts +1 -0
- package/build/angular/renderer/index.js +157 -21
- package/build/angular/renderer/index.js.map +1 -1
- package/build/angular/services/color-scheme.service.js.map +1 -1
- package/build/angular/services/window-dimensions.service.js +1 -1
- package/build/angular/services/window-dimensions.service.js.map +1 -1
- package/build/angular/state-style.d.ts +9 -0
- package/build/angular/state-style.js +39 -0
- package/build/angular/state-style.js.map +1 -0
- package/metro-transformer.cjs +69 -0
- package/package.json +27 -8
- package/src/anchor-host-registry.ts +3 -1
- package/src/bootstrap.ts +4 -1
- package/src/components/activity-indicator/index.android.ts +7 -2
- package/src/components/activity-indicator/index.ios.ts +3 -1
- package/src/components/activity-indicator/shared.ts +32 -13
- package/src/components/button.ts +36 -6
- package/src/components/flat-list/index.ts +91 -29
- package/src/components/image/index.android.ts +9 -6
- package/src/components/image/index.ios.ts +9 -6
- package/src/components/image/shared.ts +110 -45
- package/src/components/image-background.ts +47 -10
- package/src/components/input-accessory-view/index.ts +41 -18
- package/src/components/keyboard-avoiding-view/index.ts +76 -22
- package/src/components/modal/index.ts +39 -12
- package/src/components/pressable/index.ts +121 -31
- package/src/components/refresh-control/index.ts +36 -13
- package/src/components/safe-area-view/index.ts +44 -19
- package/src/components/scroll-view/index.android.ts +31 -9
- package/src/components/scroll-view/index.ios.ts +17 -4
- package/src/components/scroll-view/projection.ts +290 -63
- package/src/components/scroll-view/shared.ts +124 -30
- package/src/components/scroll-view/sticky-header.ts +25 -8
- package/src/components/section-list/index.ts +79 -19
- package/src/components/switch/index.android.ts +18 -7
- package/src/components/switch/index.ios.ts +18 -7
- package/src/components/switch/shared.ts +15 -4
- package/src/components/text-input.ts +119 -31
- package/src/components/touchable/index.ts +339 -72
- package/src/components/touchable-native-feedback/index.ts +50 -18
- package/src/components/virtualized-list/directives.ts +19 -5
- package/src/components/virtualized-list/index.ts +292 -72
- package/src/components/virtualized-section-list/directives.ts +6 -3
- package/src/components/virtualized-section-list/index.ts +164 -34
- package/src/components.ts +6 -1
- package/src/create-portal/index.ts +47 -11
- package/src/create-tunnel/index.ts +3 -1
- package/src/descriptor-to-angular/index.ts +29 -8
- package/src/diagnostics.ts +138 -0
- package/src/gate-demand.ts +109 -0
- package/src/host-instance/index.ts +9 -3
- package/src/index.ts +33 -2
- package/src/modules/animated/create-animated-component.ts +67 -19
- package/src/modules/app-registry/index.ts +3 -1
- package/src/modules/status-bar/index.ts +13 -4
- package/src/primitives/index.ts +73 -2
- package/src/primitives/shared.ts +70 -9
- package/src/register.ts +43 -0
- package/src/render/index.ts +59 -7
- package/src/renderer/index.ts +209 -37
- package/src/services/color-scheme.service.ts +3 -1
- package/src/services/window-dimensions.service.ts +11 -4
- package/src/state-style.ts +39 -0
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
AnimatedProps,
|
|
3
3
|
type AnimatedValue,
|
|
4
4
|
dlog,
|
|
5
|
+
isDebug,
|
|
5
6
|
isAnchor,
|
|
6
7
|
reduceProps,
|
|
7
8
|
routeProp,
|
|
@@ -14,6 +15,7 @@ import {
|
|
|
14
15
|
type ISymbioteEvent,
|
|
15
16
|
type ISymbioteNode,
|
|
16
17
|
} from '@symbiote-native/engine';
|
|
18
|
+
import { countAngular } from '../../diagnostics';
|
|
17
19
|
import {
|
|
18
20
|
createInitialStickyState,
|
|
19
21
|
readLayoutNumber,
|
|
@@ -26,11 +28,45 @@ import {
|
|
|
26
28
|
import { descriptorFor } from '@symbiote-native/components';
|
|
27
29
|
import { Platform } from '@symbiote-native/engine';
|
|
28
30
|
|
|
29
|
-
type IInsert = (
|
|
31
|
+
type IInsert = (
|
|
32
|
+
parent: ISymbioteNode,
|
|
33
|
+
child: ISymbioteNode,
|
|
34
|
+
beforeChild?: ISymbioteNode,
|
|
35
|
+
) => void;
|
|
30
36
|
type IRemove = (parent: ISymbioteNode, child: ISymbioteNode) => void;
|
|
31
37
|
|
|
32
|
-
const contentProjection = new WeakMap<
|
|
33
|
-
|
|
38
|
+
const contentProjection = new WeakMap<
|
|
39
|
+
ISymbioteNode,
|
|
40
|
+
ScrollViewProjectionController
|
|
41
|
+
>();
|
|
42
|
+
// Which controller owns a projected child. Keyed by EVERY projected child, not only the wrapped
|
|
43
|
+
// ones: sticky indices are positional, so a child removed behind the controller's back leaves a dead
|
|
44
|
+
// record that inflates every later child's paint index (see removeScrollViewProjectedChild). A
|
|
45
|
+
// windowed list recycles cells continuously, so that drift grows with scroll distance.
|
|
46
|
+
const projectedOwners = new WeakMap<
|
|
47
|
+
ISymbioteNode,
|
|
48
|
+
ScrollViewProjectionController
|
|
49
|
+
>();
|
|
50
|
+
|
|
51
|
+
// Controllers with a coalesced sticky reconcile owed, drained once per change-detection pass by
|
|
52
|
+
// flushScrollViewProjections (see ScrollViewProjectionController.scheduleReconcile for why the
|
|
53
|
+
// flush must be synchronous rather than a microtask). A strong Set is correct here: an entry lives
|
|
54
|
+
// only from the mutation that queued it to the end of that same pass.
|
|
55
|
+
const pendingProjections = new Set<ScrollViewProjectionController>();
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Run every sticky reconcile owed by this change-detection pass. Wired to the renderer factory's
|
|
59
|
+
* `end()`, which Angular calls at the end of `ApplicationRef.tick` — after both channels that feed
|
|
60
|
+
* a projection controller have written (children through the renderer, `stickyHeaderIndices` through
|
|
61
|
+
* an `@Input`), and before the surface's microtask-coalesced commit runs.
|
|
62
|
+
*/
|
|
63
|
+
export function flushScrollViewProjections(): void {
|
|
64
|
+
if (pendingProjections.size === 0) return;
|
|
65
|
+
// Snapshot: a reconcile can wrap a record, and wrapping is itself a projected mutation.
|
|
66
|
+
const owed = [...pendingProjections];
|
|
67
|
+
pendingProjections.clear();
|
|
68
|
+
for (const controller of owed) controller.flushReconcile();
|
|
69
|
+
}
|
|
34
70
|
|
|
35
71
|
interface IProjectedRecord {
|
|
36
72
|
child: ISymbioteNode;
|
|
@@ -58,7 +94,10 @@ function directNode(record: IProjectedRecord): ISymbioteNode {
|
|
|
58
94
|
}
|
|
59
95
|
|
|
60
96
|
function isProjectedRefreshControl(node: ISymbioteNode): boolean {
|
|
61
|
-
if (
|
|
97
|
+
if (
|
|
98
|
+
node.component === 'PullToRefreshView' ||
|
|
99
|
+
node.component === 'AndroidSwipeRefreshLayout'
|
|
100
|
+
) {
|
|
62
101
|
return true;
|
|
63
102
|
}
|
|
64
103
|
// Angular public <RefreshControl> is an anchor host whose real native refresh node lives inside
|
|
@@ -87,13 +126,16 @@ class StickyProjectionWrapper {
|
|
|
87
126
|
|
|
88
127
|
constructor(
|
|
89
128
|
private readonly controller: ScrollViewProjectionController,
|
|
90
|
-
|
|
129
|
+
// NOT readonly: a window step renumbers every paint index below it without changing which child
|
|
130
|
+
// this header is. See setChildIndex.
|
|
131
|
+
private childIndex: number,
|
|
91
132
|
private readonly node: ISymbioteNode,
|
|
92
133
|
) {
|
|
93
|
-
this.animatedTranslateY =
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
134
|
+
this.animatedTranslateY =
|
|
135
|
+
this.controller.config.scrollAnimatedValue.interpolate({
|
|
136
|
+
inputRange: [-1, 0],
|
|
137
|
+
outputRange: [0, 0],
|
|
138
|
+
});
|
|
97
139
|
dlog(
|
|
98
140
|
`STICKY[wrap] created index=${childIndex} scrollValueIsNative=${this.controller.config.scrollAnimatedValue.__isNative()}`,
|
|
99
141
|
);
|
|
@@ -123,6 +165,24 @@ class StickyProjectionWrapper {
|
|
|
123
165
|
this.dispatch({ kind: 'inputs-changed' });
|
|
124
166
|
}
|
|
125
167
|
|
|
168
|
+
// A window step renumbers every paint index below it while the header itself is unchanged: same
|
|
169
|
+
// child, same measured layout, new position among the projected children. Recreating the wrapper
|
|
170
|
+
// for that throws away `measured`/`layoutY` so the header re-measures from scratch, and costs a
|
|
171
|
+
// native round trip per step (restoreDefaultValues + dropAnimatedNode out, a fresh interpolation +
|
|
172
|
+
// AnimatedProps in). Feed the new index to the SAME state machine and let the reducer's range
|
|
173
|
+
// guard decide whether anything must be rebuilt.
|
|
174
|
+
//
|
|
175
|
+
// Identity is the CHILD - it owns the record, the record owns this wrapper - never the position.
|
|
176
|
+
// Angular CDK's virtual scroll makes the same call: on a window move `_updateContext` writes
|
|
177
|
+
// `view.context.index = renderedRange.start + i` into the EXISTING view rather than recreating it,
|
|
178
|
+
// and its trackBy is offset by `renderedRange.start` so identity stays absolute
|
|
179
|
+
// (`cdk/scrolling/virtual-for-of.ts`).
|
|
180
|
+
setChildIndex(next: number): void {
|
|
181
|
+
if (this.childIndex === next) return;
|
|
182
|
+
this.childIndex = next;
|
|
183
|
+
this.dispatch({ kind: 'inputs-changed' });
|
|
184
|
+
}
|
|
185
|
+
|
|
126
186
|
private inputs(): IStickyReducerInputs {
|
|
127
187
|
return {
|
|
128
188
|
os: Platform.OS,
|
|
@@ -141,7 +201,10 @@ class StickyProjectionWrapper {
|
|
|
141
201
|
for (const effect of effects) {
|
|
142
202
|
switch (effect.kind) {
|
|
143
203
|
case 'rebuild-interpolation': {
|
|
144
|
-
if (
|
|
204
|
+
if (
|
|
205
|
+
this.interpolation !== undefined &&
|
|
206
|
+
this.listenerId !== undefined
|
|
207
|
+
) {
|
|
145
208
|
this.interpolation.removeListener(this.listenerId);
|
|
146
209
|
this.listenerId = undefined;
|
|
147
210
|
}
|
|
@@ -159,7 +222,8 @@ class StickyProjectionWrapper {
|
|
|
159
222
|
break;
|
|
160
223
|
}
|
|
161
224
|
case 'schedule-debounce':
|
|
162
|
-
if (this.debounceTimer !== undefined)
|
|
225
|
+
if (this.debounceTimer !== undefined)
|
|
226
|
+
clearTimeout(this.debounceTimer);
|
|
163
227
|
this.debounceTimer = setTimeout(() => {
|
|
164
228
|
this.debounceTimer = undefined;
|
|
165
229
|
this.dispatch({ kind: 'debounce-fired', value: effect.value });
|
|
@@ -178,7 +242,9 @@ class StickyProjectionWrapper {
|
|
|
178
242
|
private readonly onLayout = (event: ISymbioteEvent): void => {
|
|
179
243
|
const y = readLayoutNumber(event, 'y');
|
|
180
244
|
const height = readLayoutNumber(event, 'height');
|
|
181
|
-
dlog(
|
|
245
|
+
dlog(
|
|
246
|
+
`STICKY[wrap] index=${this.childIndex} onLayout y=${y} height=${height}`,
|
|
247
|
+
);
|
|
182
248
|
// Keep the previous value when a field is absent (RN sets state only on a defined read).
|
|
183
249
|
this.dispatch({
|
|
184
250
|
kind: 'layout',
|
|
@@ -191,9 +257,17 @@ class StickyProjectionWrapper {
|
|
|
191
257
|
// so the wrapper is JS-driven end to end. If the scroll value has been made native up front the
|
|
192
258
|
// cascade to child listeners stops (AnimatedWithChildren.__callListeners skips children once
|
|
193
259
|
// isNative) and this never fires - the header then renders in place and never moves.
|
|
194
|
-
private readonly animatedValueListener = ({
|
|
195
|
-
|
|
196
|
-
|
|
260
|
+
private readonly animatedValueListener = ({
|
|
261
|
+
value,
|
|
262
|
+
}: {
|
|
263
|
+
value: number | string;
|
|
264
|
+
}): void => {
|
|
265
|
+
dlog(
|
|
266
|
+
() =>
|
|
267
|
+
`STICKY[wrap] index=${this.childIndex} animated-tick value=${String(value)}`,
|
|
268
|
+
);
|
|
269
|
+
if (typeof value === 'number')
|
|
270
|
+
this.dispatch({ kind: 'animated-tick', value });
|
|
197
271
|
};
|
|
198
272
|
|
|
199
273
|
private props(): Record<string, unknown> {
|
|
@@ -204,7 +278,10 @@ class StickyProjectionWrapper {
|
|
|
204
278
|
const passthrough =
|
|
205
279
|
this.state.translateY === null
|
|
206
280
|
? undefined
|
|
207
|
-
: {
|
|
281
|
+
: {
|
|
282
|
+
transform: [{ translateY: this.state.translateY }],
|
|
283
|
+
zIndex: STICKY_HEADER_Z_INDEX,
|
|
284
|
+
};
|
|
208
285
|
return {
|
|
209
286
|
style: passthrough === undefined ? style : [style, passthrough],
|
|
210
287
|
onLayout: this.onLayout,
|
|
@@ -224,7 +301,8 @@ class StickyProjectionWrapper {
|
|
|
224
301
|
`scrollValueIsNative=${this.controller.config.scrollAnimatedValue.__isNative()} ` +
|
|
225
302
|
`reducedTransform=${JSON.stringify(reduced.transform ?? reduced.style)}`,
|
|
226
303
|
);
|
|
227
|
-
for (const [key, value] of Object.entries(reduced))
|
|
304
|
+
for (const [key, value] of Object.entries(reduced))
|
|
305
|
+
routeProp(this.node, key, value);
|
|
228
306
|
|
|
229
307
|
const next = new AnimatedProps(props);
|
|
230
308
|
next.__attach();
|
|
@@ -244,7 +322,9 @@ class StickyProjectionWrapper {
|
|
|
244
322
|
this.cancelBind?.();
|
|
245
323
|
this.cancelBind = whenCommitted(this.node, () => {
|
|
246
324
|
next.setNativeView(this.node);
|
|
247
|
-
dlog(
|
|
325
|
+
dlog(
|
|
326
|
+
`STICKY[wrap] index=${this.childIndex} setNativeView isNative=${next.__isNative()}`,
|
|
327
|
+
);
|
|
248
328
|
});
|
|
249
329
|
}
|
|
250
330
|
}
|
|
@@ -261,6 +341,8 @@ export class ScrollViewProjectionController {
|
|
|
261
341
|
// impossible to read as either a real loop or two lists warming up in turn.
|
|
262
342
|
private readonly instanceId = ++projectionControllerSeq;
|
|
263
343
|
private reconcileSeq = 0;
|
|
344
|
+
private isReconcilePending = false;
|
|
345
|
+
private shouldNotifyAll = false;
|
|
264
346
|
|
|
265
347
|
constructor(config: IScrollViewProjectionConfig) {
|
|
266
348
|
this.config = config;
|
|
@@ -268,7 +350,46 @@ export class ScrollViewProjectionController {
|
|
|
268
350
|
|
|
269
351
|
update(config: IScrollViewProjectionConfig): void {
|
|
270
352
|
this.config = config;
|
|
271
|
-
|
|
353
|
+
// The one caller that must notify every wrapper: scrollViewHeight / invertStickyHeaders live in
|
|
354
|
+
// this config and reach the wrappers through nothing else.
|
|
355
|
+
this.scheduleReconcile(true);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Coalesce every reconcile request of one change-detection pass into a SINGLE walk.
|
|
360
|
+
*
|
|
361
|
+
* Cost is half the reason: the walk is O(records), so running it per projected mutation makes
|
|
362
|
+
* mounting M children O(M^2) - 801 children on the benchmark screen's PATH A. Correctness is the
|
|
363
|
+
* worse half. A windowed list feeds the controller through TWO channels that land at different
|
|
364
|
+
* moments in the same pass: children arrive through the renderer (insert/remove), while
|
|
365
|
+
* `stickyHeaderIndices` arrive as an ordinary `@Input` via `update()` from ngOnChanges. Reconciling
|
|
366
|
+
* on each mutation therefore guarantees at least one walk matching the NEW children against the
|
|
367
|
+
* OLD indices - and those indices are positions in the rendered child array
|
|
368
|
+
* (`stickyChildPositions`), which shift by 1-3 the moment VirtualizedList grows a leading spacer or
|
|
369
|
+
* a forced sticky cell. Every mismatch re-labels a header and re-parents engine nodes for nothing.
|
|
370
|
+
*
|
|
371
|
+
* The flush point is `RendererFactory2.end()`: the end of the pass, still SYNCHRONOUS. It must not
|
|
372
|
+
* be a microtask - engine mutations only `markDirty`, the commit is microtask-coalesced by the
|
|
373
|
+
* surface, and a reconcile queued after that commit mutates a tree nobody commits again. Headless
|
|
374
|
+
* the sticky wrapper then never reaches Fabric at all (six tests caught exactly this); on device it
|
|
375
|
+
* lands a frame late or not at all. `end()` runs inside `ApplicationRef.tick`, so it precedes every
|
|
376
|
+
* microtask queued during the pass.
|
|
377
|
+
*/
|
|
378
|
+
private scheduleReconcile(notifyAll = false): void {
|
|
379
|
+
countAngular('projectionSchedules');
|
|
380
|
+
this.shouldNotifyAll ||= notifyAll;
|
|
381
|
+
if (this.isReconcilePending) return;
|
|
382
|
+
this.isReconcilePending = true;
|
|
383
|
+
pendingProjections.add(this);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Called by the renderer factory's end(). Public only for that seam.
|
|
387
|
+
flushReconcile(): void {
|
|
388
|
+
countAngular('projectionFlushes');
|
|
389
|
+
this.isReconcilePending = false;
|
|
390
|
+
const notifyAll = this.shouldNotifyAll;
|
|
391
|
+
this.shouldNotifyAll = false;
|
|
392
|
+
this.reconcileStickyRecords(notifyAll);
|
|
272
393
|
}
|
|
273
394
|
|
|
274
395
|
bindContentNode(node: ISymbioteNode): void {
|
|
@@ -279,18 +400,29 @@ export class ScrollViewProjectionController {
|
|
|
279
400
|
contentProjection.set(node, this);
|
|
280
401
|
if (this.records.length === 0 && node.children.length > 0) {
|
|
281
402
|
for (const child of [...node.children]) {
|
|
282
|
-
this.records.push({
|
|
403
|
+
this.records.push({
|
|
404
|
+
child,
|
|
405
|
+
wrapper: undefined,
|
|
406
|
+
sticky: undefined,
|
|
407
|
+
stickyIndex: undefined,
|
|
408
|
+
});
|
|
409
|
+
projectedOwners.set(child, this);
|
|
283
410
|
}
|
|
284
411
|
}
|
|
285
|
-
this.
|
|
412
|
+
this.scheduleReconcile();
|
|
286
413
|
}
|
|
287
414
|
|
|
288
|
-
appendProjectedChild(
|
|
415
|
+
appendProjectedChild(
|
|
416
|
+
parent: ISymbioteNode,
|
|
417
|
+
child: ISymbioteNode,
|
|
418
|
+
insert: IInsert,
|
|
419
|
+
): void {
|
|
289
420
|
dlog(
|
|
290
421
|
`Angular ScrollView projection appendProjectedChild parent=${parent.component} child=${child.component} recordsBefore=${this.records.length}`,
|
|
291
422
|
);
|
|
292
423
|
const existing = this.records.find(record => record.child === child);
|
|
293
|
-
if (existing !== undefined)
|
|
424
|
+
if (existing !== undefined)
|
|
425
|
+
this.records.splice(this.records.indexOf(existing), 1);
|
|
294
426
|
const record: IProjectedRecord = {
|
|
295
427
|
child,
|
|
296
428
|
wrapper: undefined,
|
|
@@ -298,8 +430,9 @@ export class ScrollViewProjectionController {
|
|
|
298
430
|
stickyIndex: undefined,
|
|
299
431
|
};
|
|
300
432
|
this.records.push(record);
|
|
433
|
+
projectedOwners.set(child, this);
|
|
301
434
|
this.insertRecord(parent, record, undefined, insert);
|
|
302
|
-
this.
|
|
435
|
+
this.scheduleReconcile();
|
|
303
436
|
}
|
|
304
437
|
|
|
305
438
|
insertProjectedChild(
|
|
@@ -312,12 +445,14 @@ export class ScrollViewProjectionController {
|
|
|
312
445
|
`Angular ScrollView projection insertProjectedChild parent=${parent.component} child=${child.component} before=${beforeChild ? `${beforeChild.component}` : 'null'} recordsBefore=${this.records.length}`,
|
|
313
446
|
);
|
|
314
447
|
const existing = this.records.find(record => record.child === child);
|
|
315
|
-
if (existing !== undefined)
|
|
448
|
+
if (existing !== undefined)
|
|
449
|
+
this.records.splice(this.records.indexOf(existing), 1);
|
|
316
450
|
const beforeRecord =
|
|
317
451
|
beforeChild === null
|
|
318
452
|
? undefined
|
|
319
453
|
: this.records.find(
|
|
320
|
-
record =>
|
|
454
|
+
record =>
|
|
455
|
+
record.child === beforeChild || record.wrapper === beforeChild,
|
|
321
456
|
);
|
|
322
457
|
const record: IProjectedRecord = {
|
|
323
458
|
child,
|
|
@@ -325,22 +460,40 @@ export class ScrollViewProjectionController {
|
|
|
325
460
|
sticky: undefined,
|
|
326
461
|
stickyIndex: undefined,
|
|
327
462
|
};
|
|
463
|
+
// An insert-BEFORE whose anchor is not a record degrades into an append, here and again in
|
|
464
|
+
// insertRecord below - silently, which is how an ordering fault stays invisible until the screen
|
|
465
|
+
// is already wrong. A list that only ever appends (the benchmark screen's PATH A) never hits it;
|
|
466
|
+
// a virtualized list recycling cells into the middle does.
|
|
467
|
+
if (beforeChild !== null && beforeRecord === undefined) {
|
|
468
|
+
dlog(
|
|
469
|
+
() =>
|
|
470
|
+
`STICKY[proj#${this.instanceId}] ORDER insert-before anchor NOT a record ` +
|
|
471
|
+
`child=${child.component} before=${beforeChild.component} -> APPENDED to end`,
|
|
472
|
+
);
|
|
473
|
+
}
|
|
328
474
|
const index =
|
|
329
|
-
beforeRecord === undefined
|
|
475
|
+
beforeRecord === undefined
|
|
476
|
+
? this.records.length
|
|
477
|
+
: this.records.indexOf(beforeRecord);
|
|
330
478
|
this.records.splice(index, 0, record);
|
|
479
|
+
projectedOwners.set(child, this);
|
|
331
480
|
this.insertRecord(parent, record, beforeRecord, insert);
|
|
332
|
-
this.
|
|
481
|
+
this.scheduleReconcile();
|
|
333
482
|
}
|
|
334
483
|
|
|
335
484
|
removeProjectedChild(child: ISymbioteNode, remove: IRemove): boolean {
|
|
336
|
-
const record = this.records.find(
|
|
485
|
+
const record = this.records.find(
|
|
486
|
+
entry => entry.child === child || entry.wrapper === child,
|
|
487
|
+
);
|
|
337
488
|
if (record === undefined || this.contentNode === undefined) return false;
|
|
338
489
|
record.sticky?.destroy();
|
|
339
|
-
|
|
490
|
+
projectedOwners.delete(record.child);
|
|
340
491
|
const direct = directNode(record);
|
|
341
|
-
|
|
492
|
+
// The wrapper, when there is one, is the node actually parented to the content — detach
|
|
493
|
+
// whichever of the two carries the link, so a wrapped child never leaves its wrapper behind.
|
|
494
|
+
if (direct.parent !== undefined) remove(direct.parent, direct);
|
|
342
495
|
this.records.splice(this.records.indexOf(record), 1);
|
|
343
|
-
this.
|
|
496
|
+
this.scheduleReconcile();
|
|
344
497
|
return true;
|
|
345
498
|
}
|
|
346
499
|
|
|
@@ -356,7 +509,22 @@ export class ScrollViewProjectionController {
|
|
|
356
509
|
dlog(`STICKY[proj] recordHeaderLayoutY index=${index} y=${y}`);
|
|
357
510
|
if (this.headerLayoutYs.get(index) === y) return;
|
|
358
511
|
this.headerLayoutYs.set(index, y);
|
|
359
|
-
|
|
512
|
+
// EXACTLY ONE wrapper reads this value. `nextStickyHeaderY(i)` returns the first sticky index
|
|
513
|
+
// greater than i, so `index` is the "next header" of the closest sticky index BELOW it and of
|
|
514
|
+
// no one else. Broadcasting to all of them instead cost O(N) per layout and O(N^2) to settle a
|
|
515
|
+
// screen - 200 headers answered "ranges unchanged, skipped rebuild" 199 times per layout event,
|
|
516
|
+
// 40 000 dispatches to lay out the benchmark screen's PATH A.
|
|
517
|
+
const stickyIndices = this.config.stickyHeaderIndices ?? [];
|
|
518
|
+
let previous: number | undefined;
|
|
519
|
+
// Same ascending-order assumption nextStickyHeaderY already makes.
|
|
520
|
+
for (const entry of stickyIndices) {
|
|
521
|
+
if (entry >= index) break;
|
|
522
|
+
previous = entry;
|
|
523
|
+
}
|
|
524
|
+
if (previous === undefined) return;
|
|
525
|
+
this.records
|
|
526
|
+
.find(record => record.stickyIndex === previous)
|
|
527
|
+
?.sticky?.rebuild();
|
|
360
528
|
}
|
|
361
529
|
|
|
362
530
|
private insertRecord(
|
|
@@ -365,7 +533,8 @@ export class ScrollViewProjectionController {
|
|
|
365
533
|
beforeRecord: IProjectedRecord | undefined,
|
|
366
534
|
insert: IInsert,
|
|
367
535
|
): void {
|
|
368
|
-
const before =
|
|
536
|
+
const before =
|
|
537
|
+
beforeRecord === undefined ? undefined : directNode(beforeRecord);
|
|
369
538
|
dlog(
|
|
370
539
|
`STICKY[proj#${this.instanceId}] insertRecord child=${record.child.component} ` +
|
|
371
540
|
`before=${before ? before.component : 'append'} parentIsContentNode=${parent === this.contentNode}`,
|
|
@@ -374,7 +543,14 @@ export class ScrollViewProjectionController {
|
|
|
374
543
|
else insert(parent, record.child, before);
|
|
375
544
|
}
|
|
376
545
|
|
|
377
|
-
|
|
546
|
+
// notifyAll re-dispatches every sticky wrapper's inputs. Only a CONFIG change needs that
|
|
547
|
+
// (scrollViewHeight / invertStickyHeaders arrive through update() and reach the wrappers nowhere
|
|
548
|
+
// else). An INSERT must not: this walk runs once per projected child, so notifying N wrappers on
|
|
549
|
+
// each of M inserts is M x N - 801 x 200 = 160 200 dispatches to mount the benchmark screen's
|
|
550
|
+
// PATH A, versus 70 x 3 = 210 on the canary, which is why only the big screen dies. The two input
|
|
551
|
+
// changes an insert can actually cause are covered without a broadcast: a shifted childIndex by
|
|
552
|
+
// the stickyIndex branch below, and a moved neighbour by recordHeaderLayoutY.
|
|
553
|
+
private reconcileStickyRecords(notifyAll = false): void {
|
|
378
554
|
if (this.contentNode === undefined) return;
|
|
379
555
|
const seq = ++this.reconcileSeq;
|
|
380
556
|
dlog(
|
|
@@ -383,7 +559,10 @@ export class ScrollViewProjectionController {
|
|
|
383
559
|
`children=${this.contentNode?.children.length} sticky=${JSON.stringify(this.config.stickyHeaderIndices ?? [])}`,
|
|
384
560
|
);
|
|
385
561
|
const stickyIndices = new Set(this.config.stickyHeaderIndices ?? []);
|
|
386
|
-
if (
|
|
562
|
+
if (
|
|
563
|
+
this.config.customStickyHeaderComponent !== undefined &&
|
|
564
|
+
stickyIndices.size > 0
|
|
565
|
+
) {
|
|
387
566
|
dlog(
|
|
388
567
|
'STICKY[proj] uses built-in sticky wrapper; custom StickyHeaderComponent is explicit-composition only',
|
|
389
568
|
);
|
|
@@ -391,7 +570,10 @@ export class ScrollViewProjectionController {
|
|
|
391
570
|
|
|
392
571
|
let paintIndex = 0;
|
|
393
572
|
for (const record of [...this.records]) {
|
|
394
|
-
if (
|
|
573
|
+
if (
|
|
574
|
+
this.config.excludeRefreshControl &&
|
|
575
|
+
isProjectedRefreshControl(record.child)
|
|
576
|
+
) {
|
|
395
577
|
// Removed INLINE, not through removeProjectedChild: that method ends with its own
|
|
396
578
|
// reconcileStickyRecords(), so calling it from inside this walk re-enters the walk while
|
|
397
579
|
// this.records is being spliced under it - one refresh control could then drive an
|
|
@@ -399,20 +581,40 @@ export class ScrollViewProjectionController {
|
|
|
399
581
|
this.dropRecord(record);
|
|
400
582
|
continue;
|
|
401
583
|
}
|
|
402
|
-
const countsAsChild =
|
|
584
|
+
const countsAsChild =
|
|
585
|
+
!isAnchor(record.child) && !isProjectedRefreshControl(record.child);
|
|
403
586
|
const childIndex = paintIndex;
|
|
404
587
|
if (countsAsChild) paintIndex += 1;
|
|
405
588
|
const shouldWrap = countsAsChild && stickyIndices.has(childIndex);
|
|
406
589
|
// Deliberately NOT logged per child per walk: reconcile runs on every projected insert, so
|
|
407
590
|
// that shape is O(children x inserts) and buries the few lines that carry information. Only
|
|
408
591
|
// a state TRANSITION is logged, below.
|
|
409
|
-
if (shouldWrap && record.wrapper === undefined)
|
|
410
|
-
|
|
592
|
+
if (shouldWrap && record.wrapper === undefined)
|
|
593
|
+
this.wrapRecord(record, childIndex);
|
|
594
|
+
else if (!shouldWrap && record.wrapper !== undefined)
|
|
595
|
+
this.unwrapRecord(record);
|
|
411
596
|
else if (shouldWrap && record.stickyIndex !== childIndex) {
|
|
412
|
-
|
|
413
|
-
record.sticky
|
|
597
|
+
countAngular('stickyWrapperReuses');
|
|
598
|
+
record.sticky?.setChildIndex(childIndex);
|
|
414
599
|
record.stickyIndex = childIndex;
|
|
415
|
-
} else if (shouldWrap) record.sticky?.rebuild();
|
|
600
|
+
} else if (shouldWrap && notifyAll) record.sticky?.rebuild();
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// `paintIndex` above is counted off RECORDS, but the screen paints `contentNode.children`. The
|
|
604
|
+
// reconcile line at the top prints only the two COUNTS, which stay equal under any permutation
|
|
605
|
+
// - so it can never see this. Once the orders diverge, every sticky index addresses the wrong
|
|
606
|
+
// child and each wrap moves another node, so the fault compounds with scroll distance instead
|
|
607
|
+
// of correcting itself. Guarded by isDebug because it walks both lists.
|
|
608
|
+
if (!isDebug()) return;
|
|
609
|
+
const expected = this.records.map(record => directNode(record));
|
|
610
|
+
const painted = this.contentNode?.children ?? [];
|
|
611
|
+
const at = expected.findIndex((node, index) => node !== painted[index]);
|
|
612
|
+
if (at >= 0 || expected.length !== painted.length) {
|
|
613
|
+
dlog(
|
|
614
|
+
`STICKY[proj#${this.instanceId}] ORDER DIVERGED at ${at} ` +
|
|
615
|
+
`records=${expected.length} painted=${painted.length} ` +
|
|
616
|
+
`expected=${expected[at]?.component ?? 'none'} painted=${painted[at]?.component ?? 'none'}`,
|
|
617
|
+
);
|
|
416
618
|
}
|
|
417
619
|
}
|
|
418
620
|
|
|
@@ -421,9 +623,10 @@ export class ScrollViewProjectionController {
|
|
|
421
623
|
private dropRecord(record: IProjectedRecord): void {
|
|
422
624
|
if (this.contentNode === undefined) return;
|
|
423
625
|
record.sticky?.destroy();
|
|
424
|
-
|
|
626
|
+
projectedOwners.delete(record.child);
|
|
425
627
|
const direct = directNode(record);
|
|
426
|
-
if (direct.parent === this.contentNode)
|
|
628
|
+
if (direct.parent === this.contentNode)
|
|
629
|
+
removeChild(this.contentNode, direct);
|
|
427
630
|
const index = this.records.indexOf(record);
|
|
428
631
|
if (index !== -1) this.records.splice(index, 1);
|
|
429
632
|
}
|
|
@@ -438,27 +641,52 @@ export class ScrollViewProjectionController {
|
|
|
438
641
|
private wrapRecord(record: IProjectedRecord, childIndex: number): void {
|
|
439
642
|
if (this.contentNode === undefined) return;
|
|
440
643
|
const wrapper = createViewNode();
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
644
|
+
// The slot comes from `records`, the list that always knows where this record sits, never from
|
|
645
|
+
// the child's own current parent. Read off the child, a child not sitting DIRECTLY in the content
|
|
646
|
+
// has no slot to give up and the wrapper lands at the END - the wrapped cell jumps to the bottom
|
|
647
|
+
// of the scroll content, a silent reordering that compounds with scroll distance instead of
|
|
648
|
+
// correcting itself, and a windowed list reaches that state routinely. Angular CDK carries the
|
|
649
|
+
// same warning at its own insert site: insert AT the index, never insert-then-move, because an
|
|
650
|
+
// extra anchor node in between throws the move off (`cdk/scrolling/virtual-for-of.ts`,
|
|
651
|
+
// `_getEmbeddedViewArgs`) - and this file's tree is full of anchors.
|
|
652
|
+
//
|
|
653
|
+
// Order is load-bearing: place the wrapper at the slot FIRST, then move the child into it. The
|
|
654
|
+
// engine's appendChild detaches the child from its old parent, so the wrapper slides into the
|
|
655
|
+
// position the child vacates and no explicit removeChild is needed.
|
|
656
|
+
const after = this.nextDirectAfter(record);
|
|
657
|
+
if (after === undefined) appendChild(this.contentNode, wrapper);
|
|
658
|
+
else insertBefore(this.contentNode, wrapper, after);
|
|
449
659
|
appendChild(wrapper, record.child);
|
|
450
660
|
record.wrapper = wrapper;
|
|
661
|
+
countAngular('stickyWrapperCreates');
|
|
451
662
|
record.sticky = new StickyProjectionWrapper(this, childIndex, wrapper);
|
|
452
663
|
record.stickyIndex = childIndex;
|
|
453
|
-
projectedWrappers.set(record.child, record);
|
|
454
664
|
dlog(
|
|
455
665
|
`STICKY[proj#${this.instanceId}] WRAP child=${record.child.component} paintIndex=${childIndex}`,
|
|
456
666
|
);
|
|
457
667
|
}
|
|
458
668
|
|
|
669
|
+
// The painted node of the first record AFTER `record` that is actually parented to the content —
|
|
670
|
+
// i.e. the anchor the wrapper must be inserted before. Records whose node has not been inserted
|
|
671
|
+
// yet are skipped rather than treated as a slot: a record exists from the moment the renderer
|
|
672
|
+
// reports the child, which can precede its placement.
|
|
673
|
+
private nextDirectAfter(record: IProjectedRecord): ISymbioteNode | undefined {
|
|
674
|
+
for (
|
|
675
|
+
let index = this.records.indexOf(record) + 1;
|
|
676
|
+
index < this.records.length;
|
|
677
|
+
index += 1
|
|
678
|
+
) {
|
|
679
|
+
const node = directNode(this.records[index]);
|
|
680
|
+
if (node.parent === this.contentNode) return node;
|
|
681
|
+
}
|
|
682
|
+
return undefined;
|
|
683
|
+
}
|
|
684
|
+
|
|
459
685
|
private unwrapRecord(record: IProjectedRecord): void {
|
|
460
686
|
if (this.contentNode === undefined || record.wrapper === undefined) return;
|
|
461
|
-
dlog(
|
|
687
|
+
dlog(
|
|
688
|
+
`STICKY[proj#${this.instanceId}] UNWRAP child=${record.child.component}`,
|
|
689
|
+
);
|
|
462
690
|
record.sticky?.destroy();
|
|
463
691
|
record.sticky = undefined;
|
|
464
692
|
record.stickyIndex = undefined;
|
|
@@ -466,7 +694,6 @@ export class ScrollViewProjectionController {
|
|
|
466
694
|
insertBefore(this.contentNode, record.child, wrapper);
|
|
467
695
|
removeChild(this.contentNode, wrapper);
|
|
468
696
|
record.wrapper = undefined;
|
|
469
|
-
projectedWrappers.delete(record.child);
|
|
470
697
|
}
|
|
471
698
|
}
|
|
472
699
|
|
|
@@ -476,11 +703,11 @@ export function getScrollViewProjection(
|
|
|
476
703
|
return contentProjection.get(node);
|
|
477
704
|
}
|
|
478
705
|
|
|
479
|
-
export function removeScrollViewProjectedChild(
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
706
|
+
export function removeScrollViewProjectedChild(
|
|
707
|
+
child: ISymbioteNode,
|
|
708
|
+
remove: IRemove,
|
|
709
|
+
): boolean {
|
|
710
|
+
return (
|
|
711
|
+
projectedOwners.get(child)?.removeProjectedChild(child, remove) ?? false
|
|
712
|
+
);
|
|
486
713
|
}
|