@sigx/lynx-runtime-main 0.4.4 → 0.4.5

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,65 @@
1
+ /**
2
+ * Main Thread native `<list>` recycler support.
3
+ *
4
+ * Lynx's `<list>` is NOT a plain scrolling container — it's a managed recycler.
5
+ * Native pulls each visible cell by calling `componentAtIndex(cellIndex)` and
6
+ * recycles offscreen cells via `enqueueComponent(sign)`. If its
7
+ * `update-list-info` metadata is never set, native crashes during layout
8
+ * (`UIList.onLayoutCompleted` NPE — issue #120).
9
+ *
10
+ * The reference framework (`@lynx-js/react`) renders each `<list-item>` subtree
11
+ * on demand inside `componentAtIndex`. The sigx renderer is different: the
12
+ * Background Thread eagerly creates ALL `<list-item>` elements (and their
13
+ * subtrees) on the Main Thread, just like any other children. So here
14
+ * `componentAtIndex` does not render anything — it simply returns the sign of
15
+ * the already-built element for that index, appending it to the list on first
16
+ * pull so native can find it via its unique id.
17
+ *
18
+ * What this module owns:
19
+ * - Creating `<list>` via `__CreateList` (so the callbacks are registered).
20
+ * - Tracking each list's ordered `<list-item>` children (BG insertion order)
21
+ * and their per-item platform info (item-key etc.).
22
+ * - Emitting `update-list-info` diffs (insert/remove actions) at the end of
23
+ * each ops batch so native knows how many cells exist and their keys.
24
+ *
25
+ * `<list-item>` children are intercepted here instead of being appended to the
26
+ * list element directly (the recycler owns attachment via `componentAtIndex`).
27
+ */
28
+ /** True when `internalId` is a `<list>` element managed by this module. */
29
+ export declare function isListElement(internalId: number): boolean;
30
+ /** True when `internalId` is a direct `<list-item>` child of some `<list>`. */
31
+ export declare function isListChild(internalId: number): boolean;
32
+ /** Create a `<list>` element and register its recycler callbacks. */
33
+ export declare function createListElement(internalId: number): MainThreadElement;
34
+ /**
35
+ * Record a `<list-item>` inserted into a `<list>`. We do NOT append it to the
36
+ * list element here — the recycler attaches it on demand via
37
+ * `componentAtIndex`. `anchorInternalId` is the real sibling to insert before,
38
+ * or -1 to append.
39
+ */
40
+ export declare function listInsertChild(listInternalId: number, childInternalId: number, anchorInternalId: number): void;
41
+ /** Record a `<list-item>` removed from a `<list>` and detach its element. */
42
+ export declare function listRemoveChild(listInternalId: number, childInternalId: number): void;
43
+ /** Tear down a `<list>` element (detach callbacks, drop all state). */
44
+ export declare function destroyListElement(internalId: number): void;
45
+ /**
46
+ * Record a platform-info prop on a `<list-item>`. Returns true if `key` is a
47
+ * recognised platform-info key (the caller still sets it as a normal element
48
+ * attribute regardless). Safe to call before the item's insert op arrives —
49
+ * the info is stashed by child id and read at flush time.
50
+ */
51
+ export declare function noteListItemProp(childInternalId: number, key: string, value: unknown): void;
52
+ /**
53
+ * Emit `update-list-info` diffs for every list whose children changed during
54
+ * the current ops batch. Called once per batch, before the final
55
+ * `__FlushElementTree`.
56
+ *
57
+ * The diff is intentionally minimal — insert/remove only (no move/update). It
58
+ * covers the common cases (initial render, append, delete). `removeAction`
59
+ * carries ascending OLD indices; `insertAction` carries ascending NEW
60
+ * positions, each with the item's type and platform info. This matches the
61
+ * order the host/native recycler applies them (remove first, then insert).
62
+ */
63
+ export declare function flushDirtyLists(): void;
64
+ /** Reset all list state — for testing and hot reload. */
65
+ export declare function resetListState(): void;
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Main Thread native `<list>` recycler support.
3
+ *
4
+ * Lynx's `<list>` is NOT a plain scrolling container — it's a managed recycler.
5
+ * Native pulls each visible cell by calling `componentAtIndex(cellIndex)` and
6
+ * recycles offscreen cells via `enqueueComponent(sign)`. If its
7
+ * `update-list-info` metadata is never set, native crashes during layout
8
+ * (`UIList.onLayoutCompleted` NPE — issue #120).
9
+ *
10
+ * The reference framework (`@lynx-js/react`) renders each `<list-item>` subtree
11
+ * on demand inside `componentAtIndex`. The sigx renderer is different: the
12
+ * Background Thread eagerly creates ALL `<list-item>` elements (and their
13
+ * subtrees) on the Main Thread, just like any other children. So here
14
+ * `componentAtIndex` does not render anything — it simply returns the sign of
15
+ * the already-built element for that index, appending it to the list on first
16
+ * pull so native can find it via its unique id.
17
+ *
18
+ * What this module owns:
19
+ * - Creating `<list>` via `__CreateList` (so the callbacks are registered).
20
+ * - Tracking each list's ordered `<list-item>` children (BG insertion order)
21
+ * and their per-item platform info (item-key etc.).
22
+ * - Emitting `update-list-info` diffs (insert/remove actions) at the end of
23
+ * each ops batch so native knows how many cells exist and their keys.
24
+ *
25
+ * `<list-item>` children are intercepted here instead of being appended to the
26
+ * list element directly (the recycler owns attachment via `componentAtIndex`).
27
+ */
28
+ import { elements, pageUniqueId } from './element-registry.js';
29
+ /**
30
+ * Per-`<list-item>` platform-info attribute keys (kebab-case). These are
31
+ * forwarded to native both as element attributes (via the normal SET_PROP →
32
+ * __SetAttribute path) and inside `update-list-info`'s insertAction entries.
33
+ */
34
+ const PLATFORM_INFO_KEYS = new Set([
35
+ 'item-key',
36
+ 'full-span',
37
+ 'sticky-top',
38
+ 'sticky-bottom',
39
+ 'estimated-height',
40
+ 'estimated-height-px',
41
+ 'estimated-main-axis-size-px',
42
+ 'reuse-identifier',
43
+ 'recyclable',
44
+ ]);
45
+ const listsByInternalId = new Map();
46
+ const listByListID = new Map();
47
+ /** child internal id → owning list internal id */
48
+ const listItemOwner = new Map();
49
+ /** child internal id → per-item platform info ({ 'item-key': … }) */
50
+ const itemPlatformInfo = new Map();
51
+ /** True when `internalId` is a `<list>` element managed by this module. */
52
+ export function isListElement(internalId) {
53
+ return listsByInternalId.has(internalId);
54
+ }
55
+ /** True when `internalId` is a direct `<list-item>` child of some `<list>`. */
56
+ export function isListChild(internalId) {
57
+ return listItemOwner.has(internalId);
58
+ }
59
+ // ---------------------------------------------------------------------------
60
+ // Recycler callbacks (invoked by native during layout, on the Main Thread)
61
+ // ---------------------------------------------------------------------------
62
+ function componentAtIndex(_list, listID, cellIndex, operationID, _enableReuseNotification) {
63
+ const state = listByListID.get(listID);
64
+ if (!state)
65
+ return -1;
66
+ const childInternalId = state.order[cellIndex];
67
+ if (childInternalId === undefined)
68
+ return -1;
69
+ const root = elements.get(childInternalId);
70
+ if (!root)
71
+ return -1;
72
+ const sign = __GetElementUniqueID(root);
73
+ // The element already exists (BG built it eagerly). Append on first pull so
74
+ // native can resolve it by sign; the guard prevents a double-append when the
75
+ // cell scrolls back into view.
76
+ if (!state.appended.has(childInternalId)) {
77
+ __AppendElement(state.listEl, root);
78
+ state.appended.add(childInternalId);
79
+ }
80
+ __FlushElementTree(root, {
81
+ triggerLayout: true,
82
+ operationID,
83
+ elementID: sign,
84
+ listID,
85
+ });
86
+ return sign;
87
+ }
88
+ function enqueueComponent(_list, _listID, _sign) {
89
+ // No-op. Each `<list-item>` has its own dedicated, already-rendered element,
90
+ // so we never recycle one element's subtree to display a different item.
91
+ // Native detaches the offscreen view on its own; the element stays in the
92
+ // tree so `componentAtIndex` can re-surface it on scroll-back.
93
+ }
94
+ // ---------------------------------------------------------------------------
95
+ // Lifecycle, invoked from ops-apply.ts
96
+ // ---------------------------------------------------------------------------
97
+ /** Create a `<list>` element and register its recycler callbacks. */
98
+ export function createListElement(internalId) {
99
+ const listEl = __CreateList(pageUniqueId, componentAtIndex, enqueueComponent);
100
+ const listID = __GetElementUniqueID(listEl);
101
+ const state = {
102
+ internalId,
103
+ listEl,
104
+ listID,
105
+ order: [],
106
+ committed: [],
107
+ appended: new Set(),
108
+ dirty: false,
109
+ };
110
+ listsByInternalId.set(internalId, state);
111
+ listByListID.set(listID, state);
112
+ return listEl;
113
+ }
114
+ /**
115
+ * Record a `<list-item>` inserted into a `<list>`. We do NOT append it to the
116
+ * list element here — the recycler attaches it on demand via
117
+ * `componentAtIndex`. `anchorInternalId` is the real sibling to insert before,
118
+ * or -1 to append.
119
+ */
120
+ export function listInsertChild(listInternalId, childInternalId, anchorInternalId) {
121
+ const state = listsByInternalId.get(listInternalId);
122
+ if (!state)
123
+ return;
124
+ const idx = anchorInternalId === -1 ? -1 : state.order.indexOf(anchorInternalId);
125
+ if (idx === -1)
126
+ state.order.push(childInternalId);
127
+ else
128
+ state.order.splice(idx, 0, childInternalId);
129
+ listItemOwner.set(childInternalId, listInternalId);
130
+ state.dirty = true;
131
+ }
132
+ /** Record a `<list-item>` removed from a `<list>` and detach its element. */
133
+ export function listRemoveChild(listInternalId, childInternalId) {
134
+ const state = listsByInternalId.get(listInternalId);
135
+ if (!state)
136
+ return;
137
+ const idx = state.order.indexOf(childInternalId);
138
+ if (idx !== -1)
139
+ state.order.splice(idx, 1);
140
+ if (state.appended.delete(childInternalId)) {
141
+ const child = elements.get(childInternalId);
142
+ if (child)
143
+ __RemoveElement(state.listEl, child);
144
+ }
145
+ listItemOwner.delete(childInternalId);
146
+ itemPlatformInfo.delete(childInternalId);
147
+ state.dirty = true;
148
+ }
149
+ /** Tear down a `<list>` element (detach callbacks, drop all state). */
150
+ export function destroyListElement(internalId) {
151
+ const state = listsByInternalId.get(internalId);
152
+ if (!state)
153
+ return;
154
+ __UpdateListCallbacks(state.listEl, null, null);
155
+ listByListID.delete(state.listID);
156
+ for (const childId of state.order) {
157
+ listItemOwner.delete(childId);
158
+ itemPlatformInfo.delete(childId);
159
+ }
160
+ listsByInternalId.delete(internalId);
161
+ }
162
+ /**
163
+ * Record a platform-info prop on a `<list-item>`. Returns true if `key` is a
164
+ * recognised platform-info key (the caller still sets it as a normal element
165
+ * attribute regardless). Safe to call before the item's insert op arrives —
166
+ * the info is stashed by child id and read at flush time.
167
+ */
168
+ export function noteListItemProp(childInternalId, key, value) {
169
+ if (!PLATFORM_INFO_KEYS.has(key))
170
+ return;
171
+ let info = itemPlatformInfo.get(childInternalId);
172
+ if (!info) {
173
+ info = {};
174
+ itemPlatformInfo.set(childInternalId, info);
175
+ }
176
+ info[key] = value;
177
+ const owner = listItemOwner.get(childInternalId);
178
+ if (owner !== undefined) {
179
+ const st = listsByInternalId.get(owner);
180
+ if (st)
181
+ st.dirty = true;
182
+ }
183
+ }
184
+ /**
185
+ * Emit `update-list-info` diffs for every list whose children changed during
186
+ * the current ops batch. Called once per batch, before the final
187
+ * `__FlushElementTree`.
188
+ *
189
+ * The diff is intentionally minimal — insert/remove only (no move/update). It
190
+ * covers the common cases (initial render, append, delete). `removeAction`
191
+ * carries ascending OLD indices; `insertAction` carries ascending NEW
192
+ * positions, each with the item's type and platform info. This matches the
193
+ * order the host/native recycler applies them (remove first, then insert).
194
+ */
195
+ export function flushDirtyLists() {
196
+ for (const state of listsByInternalId.values()) {
197
+ if (!state.dirty)
198
+ continue;
199
+ state.dirty = false;
200
+ const oldArr = state.committed;
201
+ const newArr = state.order;
202
+ const oldSet = new Set(oldArr);
203
+ const newSet = new Set(newArr);
204
+ const removeAction = [];
205
+ for (let i = 0; i < oldArr.length; i++) {
206
+ if (!newSet.has(oldArr[i]))
207
+ removeAction.push(i);
208
+ }
209
+ const insertAction = [];
210
+ for (let pos = 0; pos < newArr.length; pos++) {
211
+ const childInternalId = newArr[pos];
212
+ if (!oldSet.has(childInternalId)) {
213
+ const info = itemPlatformInfo.get(childInternalId) ?? {};
214
+ insertAction.push({ position: pos, type: 'list-item', ...info });
215
+ }
216
+ }
217
+ state.committed = newArr.slice();
218
+ if (removeAction.length === 0 && insertAction.length === 0)
219
+ continue;
220
+ __SetAttribute(state.listEl, 'update-list-info', {
221
+ insertAction,
222
+ removeAction,
223
+ updateAction: [],
224
+ });
225
+ // Re-affirm the callbacks after every diff (mirrors the reference runtime).
226
+ // Our closures read live state, so this is a defensive no-op rebind.
227
+ __UpdateListCallbacks(state.listEl, componentAtIndex, enqueueComponent);
228
+ }
229
+ }
230
+ /** Reset all list state — for testing and hot reload. */
231
+ export function resetListState() {
232
+ listsByInternalId.clear();
233
+ listByListID.clear();
234
+ listItemOwner.clear();
235
+ itemPlatformInfo.clear();
236
+ }
package/dist/ops-apply.js CHANGED
@@ -11,6 +11,7 @@ import { elements, pageUniqueId, setPageUniqueId, } from './element-registry.js'
11
11
  import { resetWorkletEvents } from './worklet-events.js';
12
12
  import { setSlotBgSign, setSlotWorklet, flushDirtySlots, resetSlotStates, } from './event-slots.js';
13
13
  import { flushAvBridgePublishes, flushAnimatedStyleBindings, registerAnimatedStyleBinding, unregisterAnimatedStyleBinding, resetAnimatedStyleBindings, } from './animated-bridge-mt.js';
14
+ import { createListElement, destroyListElement, flushDirtyLists, isListElement, listInsertChild, listRemoveChild, noteListItemProp, resetListState, } from './list-mt.js';
14
15
  /**
15
16
  * Placeholder element inserted by renderPage() to give the host a non-empty
16
17
  * tree immediately, suppressing the "loadCard failed USER_RUNTIME_ERROR"
@@ -127,6 +128,12 @@ export function applyOps(ops) {
127
128
  if (type === '__comment') {
128
129
  el = __CreateRawText('');
129
130
  }
131
+ else if (type === 'list') {
132
+ // `<list>` is created via __CreateList so its recycler callbacks are
133
+ // registered up front (issue #120). list-mt.ts owns its state.
134
+ el = createListElement(id);
135
+ __SetCSSId([el], 0);
136
+ }
130
137
  else {
131
138
  el = createTypedElement(type, pageUniqueId);
132
139
  __SetCSSId([el], 0);
@@ -149,6 +156,12 @@ export function applyOps(ops) {
149
156
  const parentId = ops[i++];
150
157
  const childId = ops[i++];
151
158
  const anchorId = ops[i++];
159
+ // `<list>` children are owned by the recycler — record order instead of
160
+ // appending; native attaches them on demand via componentAtIndex.
161
+ if (isListElement(parentId)) {
162
+ listInsertChild(parentId, childId, anchorId);
163
+ break;
164
+ }
152
165
  const parent = elements.get(parentId);
153
166
  const child = elements.get(childId);
154
167
  if (parent && child) {
@@ -166,6 +179,13 @@ export function applyOps(ops) {
166
179
  case OP.REMOVE: {
167
180
  const _parentId = ops[i++];
168
181
  const childId = ops[i++];
182
+ if (isListElement(_parentId)) {
183
+ listRemoveChild(_parentId, childId);
184
+ break;
185
+ }
186
+ // Tearing down a `<list>` itself — detach its recycler callbacks first.
187
+ if (isListElement(childId))
188
+ destroyListElement(childId);
169
189
  const parent = elements.get(_parentId);
170
190
  const child = elements.get(childId);
171
191
  if (parent && child) {
@@ -180,6 +200,9 @@ export function applyOps(ops) {
180
200
  const el = elements.get(id);
181
201
  if (el)
182
202
  __SetAttribute(el, key, value);
203
+ // Mirror `<list-item>` platform-info props (item-key, full-span, …)
204
+ // into the list's update-list-info metadata (no-op for other keys).
205
+ noteListItemProp(id, key, value);
183
206
  break;
184
207
  }
185
208
  case OP.SET_TEXT: {
@@ -424,6 +447,9 @@ export function applyOps(ops) {
424
447
  // during this batch. Runs after flushAvBridgePublishes so the BG mirror
425
448
  // stays consistent with the styles we're about to commit.
426
449
  flushAnimatedStyleBindings();
450
+ // Emit update-list-info diffs for any `<list>` whose children changed this
451
+ // batch, so native knows its cell count/keys before it lays out.
452
+ flushDirtyLists();
427
453
  // Flush all pending PAPI changes to the native layer in one shot.
428
454
  __FlushElementTree();
429
455
  }
@@ -442,6 +468,7 @@ export function resetMainThreadState() {
442
468
  lastTreeByElementWvid.clear();
443
469
  elementIdByWvid.clear();
444
470
  resetAnimatedStyleBindings();
471
+ resetListState();
445
472
  // Clear upstream's worklet ref map too on hard reset (HMR / test).
446
473
  const impl = globalThis['lynxWorkletImpl'];
447
474
  if (impl?._refImpl)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sigx/lynx-runtime-main",
3
- "version": "0.4.4",
3
+ "version": "0.4.5",
4
4
  "description": "Main Thread (Lepus) entry and ops applier for SignalX Lynx renderer",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -42,7 +42,7 @@
42
42
  "url": "https://github.com/signalxjs/lynx/issues"
43
43
  },
44
44
  "dependencies": {
45
- "@sigx/lynx-runtime-internal": "0.4.4"
45
+ "@sigx/lynx-runtime-internal": "0.4.5"
46
46
  },
47
47
  "peerDependencies": {
48
48
  "@lynx-js/types": ">=3.0.0"