mithril-lynx 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTRACT.md +151 -0
- package/LICENSE +21 -0
- package/README.md +227 -0
- package/background.d.ts +54 -0
- package/background.js +169 -0
- package/element.d.ts +34 -0
- package/element.js +79 -0
- package/gesture.d.ts +40 -0
- package/gesture.js +117 -0
- package/internal/constants.js +26 -0
- package/internal/virtual-node.js +388 -0
- package/list.d.ts +31 -0
- package/list.js +185 -0
- package/main-thread.d.ts +43 -0
- package/main-thread.js +165 -0
- package/package.json +100 -0
- package/plugin.d.ts +17 -0
- package/plugin.js +185 -0
- package/renderer/background.d.ts +21 -0
- package/renderer/background.js +84 -0
- package/renderer/main-thread.d.ts +12 -0
- package/renderer/main-thread.js +175 -0
- package/src/lynx-mithril-shim.d.ts +16 -0
- package/src/lynx-mithril-shim.js +1477 -0
- package/src/worklet-runtime.js +82 -0
- package/testing.d.ts +10 -0
- package/testing.js +91 -0
package/list.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Ambient declaration for the ESM list.js (the file itself is not
|
|
2
|
+
// type-checked; this describes its runtime export shape for TS consumers).
|
|
3
|
+
|
|
4
|
+
export interface CreateListOptions {
|
|
5
|
+
itemCount: number;
|
|
6
|
+
/** Must return a fresh vnode for the cell's content; may be called more than once for the same index (recycling). */
|
|
7
|
+
renderItem(index: number): unknown;
|
|
8
|
+
/** Defaults to String(index). Set on every item's "item-key" attribute — required by native, not just an identity hint. */
|
|
9
|
+
itemKey?(index: number): string;
|
|
10
|
+
className?: string;
|
|
11
|
+
/** Defaults to "vertical". */
|
|
12
|
+
scrollOrientation?: "vertical" | "horizontal";
|
|
13
|
+
/** Defaults to "single". */
|
|
14
|
+
listType?: "single" | "flow";
|
|
15
|
+
/** Defaults to 1. */
|
|
16
|
+
spanCount?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ListHandle {
|
|
20
|
+
_handle: unknown;
|
|
21
|
+
nodeType: 1;
|
|
22
|
+
/** Call after mutating the underlying data so a subsequently-scrolled-into-view cell reflects the new count. */
|
|
23
|
+
setItemCount(nextCount: number): void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Creates a native-recycled `<list>` element (project plan, Phase 8, Tier
|
|
28
|
+
* 2). Imperative escape hatch — call from oncreate(vnode) and attach the
|
|
29
|
+
* result yourself (parentNode.appendChild(list)).
|
|
30
|
+
*/
|
|
31
|
+
export function createList(parentNode: { ownerDocument: unknown }, options: CreateListOptions): ListHandle;
|
package/list.js
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// list.js
|
|
2
|
+
//
|
|
3
|
+
// List virtualization/recycling (project plan, Phase 8, Tier 2), main-thread
|
|
4
|
+
// only. __CreateList's componentAtIndex/enqueueComponent callbacks are a
|
|
5
|
+
// native-driven recycler: native owns scroll position and cell lifecycle,
|
|
6
|
+
// calling back into JS to fetch or recycle a cell's content by TYPE
|
|
7
|
+
// (matching RecyclerView/UICollectionView semantics), not a JS-side
|
|
8
|
+
// windowing calculation.
|
|
9
|
+
//
|
|
10
|
+
// The sign/recycle map design and the exact __FlushElementTree({triggerLayout,
|
|
11
|
+
// operationID, elementID, listID}) call shape are ported from
|
|
12
|
+
// @lynx-js/react's OWN shipped runtime/lib/snapshot/list/list.js — real,
|
|
13
|
+
// proven code, not guesswork (unlike gesture.js's callback-shape question,
|
|
14
|
+
// this part IS verified against a real implementation). What list.js narrows
|
|
15
|
+
// down for v1, deliberately:
|
|
16
|
+
// - No deferred list items (ReactLynx's `defer`/`isReady` promise dance —
|
|
17
|
+
// ties into a whole separate lifecycle-event protocol not otherwise
|
|
18
|
+
// needed here).
|
|
19
|
+
// - No componentAtIndexes batching — native's single-cell componentAtIndex
|
|
20
|
+
// callback only.
|
|
21
|
+
// - No independent per-item redraw after the initial bind/recycle — a list
|
|
22
|
+
// item's content is (re)computed fresh from renderItem(index) every time
|
|
23
|
+
// native calls componentAtIndex for it (on scroll-driven reuse), not
|
|
24
|
+
// whenever the app's own state changes. An app wanting a currently-bound
|
|
25
|
+
// visible item to reflect a data change needs its OWN mechanism to ask
|
|
26
|
+
// native to re-request that cell; list.js does not provide one in v1.
|
|
27
|
+
//
|
|
28
|
+
// This is an imperative escape hatch, like element.js/gesture.js — call it
|
|
29
|
+
// from oncreate(vnode) and attach the result yourself
|
|
30
|
+
// (parentNode.appendChild(list)), rather than mounting it through m().
|
|
31
|
+
// Tier 1 (no code here at all — just m("list", ...)/m("list-item", ...) as
|
|
32
|
+
// ordinary tags through the existing shim, relying on its already-tested
|
|
33
|
+
// keyed/LIS diff) covers the common case and should be preferred unless the
|
|
34
|
+
// app specifically needs native-driven recycling for very large lists.
|
|
35
|
+
|
|
36
|
+
import shim from "./src/lynx-mithril-shim.js";
|
|
37
|
+
|
|
38
|
+
// A render function dedicated to list-item content, independent of
|
|
39
|
+
// shim.render()/redraw()'s own single-root convenience-API state (which a
|
|
40
|
+
// main-thread-owned or data-channel-mode app may already be using for the
|
|
41
|
+
// rest of the page). Each item wrapper carries its own `.vnodes`, so reusing
|
|
42
|
+
// one render function sequentially across many items/lists is safe — Mithril
|
|
43
|
+
// diffs against whatever `.vnodes` is already on the target wrapper.
|
|
44
|
+
const renderItemContent = shim();
|
|
45
|
+
|
|
46
|
+
function typeKeyOf(vnode) {
|
|
47
|
+
return typeof vnode.tag === "string" ? vnode.tag : (vnode.tag && vnode.tag.name) || "default";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Creates a native-recycled `<list>` element, scoped to the same component
|
|
52
|
+
* as `parentNode` (anything with an `ownerDocument`, e.g. a real
|
|
53
|
+
* LynxNodeWrapper). `renderItem(index)` must return a fresh Mithril vnode
|
|
54
|
+
* for that cell's content every time it's called — it may be called more
|
|
55
|
+
* than once for the same index (recycling).
|
|
56
|
+
*
|
|
57
|
+
* `scrollOrientation`/`listType`/`spanCount` and each item's `item-key` are
|
|
58
|
+
* NOT optional in practice, even though native's __CreateList/componentAtIndex
|
|
59
|
+
* types don't require them: every real @lynx-js/react list example sets all
|
|
60
|
+
* of these unconditionally (verified by reading lynx-examples/examples/list's
|
|
61
|
+
* base/recyclable/async-rendering demos — see ../LIST_INVESTIGATION.md), and
|
|
62
|
+
* a real device never called componentAtIndex at all without them. Defaults
|
|
63
|
+
* here match what those examples use for the simple single-column case.
|
|
64
|
+
*/
|
|
65
|
+
export function createList(parentNode, options) {
|
|
66
|
+
const {
|
|
67
|
+
itemCount,
|
|
68
|
+
renderItem,
|
|
69
|
+
itemKey,
|
|
70
|
+
className,
|
|
71
|
+
scrollOrientation = "vertical",
|
|
72
|
+
listType = "single",
|
|
73
|
+
spanCount = 1,
|
|
74
|
+
} = options;
|
|
75
|
+
let count = itemCount;
|
|
76
|
+
const pageId = parentNode.ownerDocument._pageId;
|
|
77
|
+
const keyOf = typeof itemKey === "function" ? itemKey : (index) => String(index);
|
|
78
|
+
|
|
79
|
+
// itemType -> Map<sign, ItemEntry>, the reuse pool.
|
|
80
|
+
const recycleMap = new Map();
|
|
81
|
+
// sign -> ItemEntry, currently-bound (visible) items.
|
|
82
|
+
const signMap = new Map();
|
|
83
|
+
|
|
84
|
+
function bindFreshItem(listHandle, listId, cellIndex, opId, vnode, typeKey) {
|
|
85
|
+
const itemWrapper = parentNode.ownerDocument.createElement("list-item");
|
|
86
|
+
__SetAttribute(itemWrapper._handle, "item-key", keyOf(cellIndex));
|
|
87
|
+
__AppendElement(listHandle, itemWrapper._handle);
|
|
88
|
+
renderItemContent(itemWrapper, vnode);
|
|
89
|
+
|
|
90
|
+
const sign = __GetElementUniqueID(itemWrapper._handle);
|
|
91
|
+
signMap.set(sign, { wrapper: itemWrapper, typeKey });
|
|
92
|
+
__FlushElementTree(itemWrapper._handle, { triggerLayout: true, operationID: opId, elementID: sign, listID: listId });
|
|
93
|
+
return sign;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function bindRecycledItem(listId, cellIndex, opId, vnode, pool) {
|
|
97
|
+
const [sign, entry] = pool.entries().next().value;
|
|
98
|
+
pool.delete(sign);
|
|
99
|
+
// The recycled wrapper is now bound to a DIFFERENT index — its
|
|
100
|
+
// item-key must be updated to match, the same way its content does.
|
|
101
|
+
__SetAttribute(entry.wrapper._handle, "item-key", keyOf(cellIndex));
|
|
102
|
+
renderItemContent(entry.wrapper, vnode);
|
|
103
|
+
signMap.set(sign, entry);
|
|
104
|
+
__FlushElementTree(entry.wrapper._handle, { triggerLayout: true, operationID: opId, elementID: sign, listID: listId });
|
|
105
|
+
return sign;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function componentAtIndex(listHandle, listId, cellIndex, opId) {
|
|
109
|
+
if (cellIndex < 0 || cellIndex >= count) {
|
|
110
|
+
throw new Error(`mithril-lynx list: cellIndex ${cellIndex} out of range (itemCount=${count})`);
|
|
111
|
+
}
|
|
112
|
+
const vnode = renderItem(cellIndex);
|
|
113
|
+
const typeKey = typeKeyOf(vnode);
|
|
114
|
+
const pool = recycleMap.get(typeKey);
|
|
115
|
+
if (pool && pool.size > 0) return bindRecycledItem(listId, cellIndex, opId, vnode, pool);
|
|
116
|
+
return bindFreshItem(listHandle, listId, cellIndex, opId, vnode, typeKey);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function enqueueComponent(_listHandle, _listId, sign) {
|
|
120
|
+
const entry = signMap.get(sign);
|
|
121
|
+
if (entry == null) return;
|
|
122
|
+
signMap.delete(sign);
|
|
123
|
+
if (!recycleMap.has(entry.typeKey)) recycleMap.set(entry.typeKey, new Map());
|
|
124
|
+
recycleMap.get(entry.typeKey).set(sign, entry);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// THE missing piece (see LIST_INVESTIGATION.md): __CreateList + attributes
|
|
128
|
+
// alone never triggers componentAtIndex — native only starts requesting
|
|
129
|
+
// cells once it's told, via a special "update-list-info" attribute, which
|
|
130
|
+
// positions currently exist. Ported from @lynx-js/react's OWN
|
|
131
|
+
// runtime/lib/snapshot/list/listUpdateInfo.js (ListUpdateInfoRecording.flush()),
|
|
132
|
+
// which sends this on every list-children change, alongside a matching
|
|
133
|
+
// __UpdateListCallbacks call. Each insertAction entry needs an "item-key"
|
|
134
|
+
// (dash-case — confirmed by a real device rejecting the first attempt,
|
|
135
|
+
// which omitted it, with "Error for illegal list item-key in parse
|
|
136
|
+
// insertAction"; the exact key name matches ReactLynx's own
|
|
137
|
+
// `__listItemPlatformInfo['item-key']`, a raw JSX-prop-shaped bag it
|
|
138
|
+
// spreads into each entry). `type` per entry is still UNCONFIRMED —
|
|
139
|
+
// ReactLynx's version uses its own component-type reference there
|
|
140
|
+
// (meaningful only to its own recycling, not to native, as far as this
|
|
141
|
+
// file's reading of the source can tell); a constant string is used
|
|
142
|
+
// here since this port's own type-based recycling (typeKeyOf(), above)
|
|
143
|
+
// is tracked independently, in this module, not through native's copy
|
|
144
|
+
// of `type`.
|
|
145
|
+
function sendListInfo(insertAction, removeAction, updateAction) {
|
|
146
|
+
__SetAttribute(listHandle, "update-list-info", { insertAction, removeAction, updateAction });
|
|
147
|
+
__UpdateListCallbacks(listHandle, componentAtIndex, enqueueComponent);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function insertEntry(position) {
|
|
151
|
+
return { position, type: "cell", "item-key": keyOf(position) };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const listHandle = __CreateList(pageId, componentAtIndex, enqueueComponent, {});
|
|
155
|
+
__SetAttribute(listHandle, "scroll-orientation", scrollOrientation);
|
|
156
|
+
__SetAttribute(listHandle, "list-type", listType);
|
|
157
|
+
__SetAttribute(listHandle, "span-count", String(spanCount));
|
|
158
|
+
if (className != null) __SetClasses(listHandle, className);
|
|
159
|
+
|
|
160
|
+
sendListInfo(
|
|
161
|
+
Array.from({ length: count }, (_, position) => insertEntry(position)),
|
|
162
|
+
[],
|
|
163
|
+
[],
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
_handle: listHandle,
|
|
168
|
+
nodeType: 1,
|
|
169
|
+
/** Call after mutating the underlying data so a subsequently-scrolled-into-view cell reflects the new count. */
|
|
170
|
+
setItemCount(nextCount) {
|
|
171
|
+
if (nextCount === count) return;
|
|
172
|
+
if (nextCount > count) {
|
|
173
|
+
sendListInfo(
|
|
174
|
+
Array.from({ length: nextCount - count }, (_, i) => insertEntry(count + i)),
|
|
175
|
+
[],
|
|
176
|
+
[],
|
|
177
|
+
);
|
|
178
|
+
} else {
|
|
179
|
+
const removed = Array.from({ length: count - nextCount }, (_, i) => nextCount + i);
|
|
180
|
+
sendListInfo([], removed, []);
|
|
181
|
+
}
|
|
182
|
+
count = nextCount;
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
}
|
package/main-thread.d.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Ambient declaration for the ESM main-thread.js (the file itself is not
|
|
2
|
+
// type-checked; this describes its runtime export shape for TS consumers).
|
|
3
|
+
|
|
4
|
+
/** Returns the most recently received data (from __RenderPage/__UpdatePage or a background push). */
|
|
5
|
+
export function getData<T = unknown>(): T | undefined;
|
|
6
|
+
|
|
7
|
+
/** Sends a named action to the background thread's setBackgroundEventHandler. */
|
|
8
|
+
export function dispatchToBackground(handlerName: string, data?: unknown): void;
|
|
9
|
+
|
|
10
|
+
export interface SetupAppOptions<TInput = unknown, TData = TInput> {
|
|
11
|
+
/** Called exactly once, on the first __RenderPage, to build the root Mithril vnode. */
|
|
12
|
+
root(): unknown;
|
|
13
|
+
/** Optional transform applied to raw engine/background data before storing it. */
|
|
14
|
+
processData?(data: TInput): TData;
|
|
15
|
+
/**
|
|
16
|
+
* When true (default), pushes processed data to the background thread on
|
|
17
|
+
* every __RenderPage/__UpdatePage, and listens for background-pushed
|
|
18
|
+
* updates, redrawing via Mithril's own shim.redraw().
|
|
19
|
+
*/
|
|
20
|
+
enableBackgroundSync?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Wires the Lynx engine's page lifecycle (__RenderPage/__UpdatePage/
|
|
25
|
+
* __DestroyLifetime) to a Mithril app: renders `root()` once, then calls
|
|
26
|
+
* the shim's redraw() on every subsequent data push. See the project plan,
|
|
27
|
+
* Phase 3 ("data-channel mode").
|
|
28
|
+
*/
|
|
29
|
+
export function setupApp<TInput = unknown, TData = TInput>(
|
|
30
|
+
options: SetupAppOptions<TInput, TData>,
|
|
31
|
+
): void;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Registers a handler background.js's runOnMainThread(key, ...args) can
|
|
35
|
+
* call by name. See the project plan, Phase 6.
|
|
36
|
+
*/
|
|
37
|
+
export function registerHandler(key: string, fn: (...args: unknown[]) => unknown): void;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Calls a handler background.js registered via registerHandler(key, fn).
|
|
41
|
+
* Args and the resolved value must be JSON-serializable.
|
|
42
|
+
*/
|
|
43
|
+
export function runOnBackground<T = unknown>(key: string, ...args: unknown[]): Promise<T>;
|
package/main-thread.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// main-thread.js
|
|
2
|
+
//
|
|
3
|
+
// Cross-thread "data-channel mode" adapter for the main thread (see the
|
|
4
|
+
// project plan, Phase 3). Ports the engine-lifecycle wiring from
|
|
5
|
+
// lynx-examples/examples/vanilla/src/common/main-thread/setup.ts, adapted so
|
|
6
|
+
// the app supplies a Mithril root component instead of hand-written
|
|
7
|
+
// renderPage/updatePage functions: Mithril's own redraw/diff machinery
|
|
8
|
+
// (already ported in ../src/lynx-mithril-shim.js) does the update work.
|
|
9
|
+
//
|
|
10
|
+
// The app is responsible for reading live data inside its Mithril view()
|
|
11
|
+
// functions via getData() — exactly like any plain-JS Mithril store, no
|
|
12
|
+
// special re-invocation of a "mount" function is needed on every update.
|
|
13
|
+
|
|
14
|
+
import shim from "./src/lynx-mithril-shim.js";
|
|
15
|
+
import {
|
|
16
|
+
callBackgroundEventName,
|
|
17
|
+
callBackgroundResultEventName,
|
|
18
|
+
callMainThreadEventName,
|
|
19
|
+
callMainThreadResultEventName,
|
|
20
|
+
destroyLifetimeEventName,
|
|
21
|
+
dispatchEventToBackgroundEventName,
|
|
22
|
+
renderPageEventName,
|
|
23
|
+
updateDataFromBackgroundEventName,
|
|
24
|
+
updateDataFromMainThreadEventName,
|
|
25
|
+
updatePageEventName,
|
|
26
|
+
} from "./internal/constants.js";
|
|
27
|
+
|
|
28
|
+
let latestData;
|
|
29
|
+
|
|
30
|
+
// The native engine unconditionally invokes a global `processData(initData)`
|
|
31
|
+
// hook on every __RenderPage/__UpdatePage, regardless of framework. Install a
|
|
32
|
+
// pass-through default immediately so apps that don't need to transform
|
|
33
|
+
// incoming data (the common case) don't have to think about this at all.
|
|
34
|
+
Object.assign(globalThis, {
|
|
35
|
+
processData: (data) => data,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
export function getData() {
|
|
39
|
+
return latestData;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function dispatchToBackground(handlerName, data) {
|
|
43
|
+
lynx.getJSContext().dispatchEvent({
|
|
44
|
+
type: dispatchEventToBackgroundEventName,
|
|
45
|
+
data: { handlerName, data },
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function setupApp(options) {
|
|
50
|
+
const { root, processData, enableBackgroundSync = true } = options;
|
|
51
|
+
const engine = lynx.getEngine();
|
|
52
|
+
const background = enableBackgroundSync ? lynx.getJSContext() : undefined;
|
|
53
|
+
let rendered = false;
|
|
54
|
+
|
|
55
|
+
const applyData = (data) => {
|
|
56
|
+
latestData = typeof processData === "function" ? processData(data) : data;
|
|
57
|
+
if (enableBackgroundSync) {
|
|
58
|
+
lynx.getJSContext().dispatchEvent({
|
|
59
|
+
type: updateDataFromMainThreadEventName,
|
|
60
|
+
data: latestData,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
return latestData;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const onRenderPage = (event) => {
|
|
67
|
+
const [data] = event.data;
|
|
68
|
+
applyData(data);
|
|
69
|
+
const page = __CreatePage("0", 0);
|
|
70
|
+
shim.renderToPage(page, root());
|
|
71
|
+
rendered = true;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const onUpdatePage = (event) => {
|
|
75
|
+
const [data] = event.data;
|
|
76
|
+
applyData(data);
|
|
77
|
+
if (rendered) shim.redraw();
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const onDataFromBackground = (event) => {
|
|
81
|
+
latestData = { ...latestData, ...event.data };
|
|
82
|
+
if (rendered) shim.redraw();
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const onDestroyLifetime = () => {
|
|
86
|
+
if (enableBackgroundSync) {
|
|
87
|
+
// The background thread has no lynx.getEngine() of its own, so relay
|
|
88
|
+
// the native lifecycle event across the channel explicitly — this is
|
|
89
|
+
// what lets background.js's own setupBackground() cleanup run.
|
|
90
|
+
background.dispatchEvent({ type: destroyLifetimeEventName, data: undefined });
|
|
91
|
+
background.removeEventListener(updateDataFromBackgroundEventName, onDataFromBackground);
|
|
92
|
+
}
|
|
93
|
+
engine.removeEventListener(renderPageEventName, onRenderPage);
|
|
94
|
+
engine.removeEventListener(updatePageEventName, onUpdatePage);
|
|
95
|
+
engine.removeEventListener(destroyLifetimeEventName, onDestroyLifetime);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
engine.addEventListener(renderPageEventName, onRenderPage);
|
|
99
|
+
engine.addEventListener(updatePageEventName, onUpdatePage);
|
|
100
|
+
engine.addEventListener(destroyLifetimeEventName, onDestroyLifetime);
|
|
101
|
+
if (enableBackgroundSync) {
|
|
102
|
+
background.addEventListener(updateDataFromBackgroundEventName, onDataFromBackground);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Cross-thread function registry (project plan, Phase 6 — worklet
|
|
107
|
+
// substitute). The common worklet use case (a gesture/tap handler running
|
|
108
|
+
// on the thread it's authored on) needs NONE of this — it's already just an
|
|
109
|
+
// ordinary function in main-thread.ts. This is only for the remaining case:
|
|
110
|
+
// background-owned code needs to trigger a main-thread action outside the
|
|
111
|
+
// normal render/data cycle. Equal *capability* to upstream ReactLynx's own
|
|
112
|
+
// runOnMainThread/runOnBackground (both are async serialized RPC under the
|
|
113
|
+
// hood there too) — worse *ergonomics* only, since there's no compiler to
|
|
114
|
+
// extract an inline closure; handlers must be named and pre-registered on
|
|
115
|
+
// the thread they run on. Listener setup is lazy (on first use), not
|
|
116
|
+
// module-top-level, so importing this module never assumes a particular
|
|
117
|
+
// thread is active yet.
|
|
118
|
+
const mainThreadHandlers = new Map();
|
|
119
|
+
const pendingBackgroundCalls = new Map();
|
|
120
|
+
let nextCallId = 1;
|
|
121
|
+
let crossThreadCallsReady = false;
|
|
122
|
+
|
|
123
|
+
function ensureCrossThreadCalls() {
|
|
124
|
+
if (crossThreadCallsReady) return;
|
|
125
|
+
crossThreadCallsReady = true;
|
|
126
|
+
const background = lynx.getJSContext();
|
|
127
|
+
|
|
128
|
+
background.addEventListener(callMainThreadEventName, (event) => {
|
|
129
|
+
const { callId, key, args } = event.data;
|
|
130
|
+
const fn = mainThreadHandlers.get(key);
|
|
131
|
+
let result;
|
|
132
|
+
let error;
|
|
133
|
+
try {
|
|
134
|
+
result = fn ? fn(...args) : undefined;
|
|
135
|
+
} catch (e) {
|
|
136
|
+
error = e instanceof Error ? e.message : String(e);
|
|
137
|
+
}
|
|
138
|
+
background.dispatchEvent({ type: callMainThreadResultEventName, data: { callId, result, error } });
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
background.addEventListener(callBackgroundResultEventName, (event) => {
|
|
142
|
+
const { callId, result, error } = event.data;
|
|
143
|
+
const pending = pendingBackgroundCalls.get(callId);
|
|
144
|
+
if (pending == null) return;
|
|
145
|
+
pendingBackgroundCalls.delete(callId);
|
|
146
|
+
if (error != null) pending.reject(new Error(error));
|
|
147
|
+
else pending.resolve(result);
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Registers a handler background.runOnMainThread(key, ...) can call by name. */
|
|
152
|
+
export function registerHandler(key, fn) {
|
|
153
|
+
ensureCrossThreadCalls();
|
|
154
|
+
mainThreadHandlers.set(key, fn);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Calls a handler background.js registered via registerHandler(key, fn), by name. Args must be JSON-serializable. */
|
|
158
|
+
export function runOnBackground(key, ...args) {
|
|
159
|
+
ensureCrossThreadCalls();
|
|
160
|
+
return new Promise((resolve, reject) => {
|
|
161
|
+
const callId = nextCallId++;
|
|
162
|
+
pendingBackgroundCalls.set(callId, { resolve, reject });
|
|
163
|
+
lynx.getJSContext().dispatchEvent({ type: callBackgroundEventName, data: { callId, key, args } });
|
|
164
|
+
});
|
|
165
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "mithril-lynx",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Mithril.js rendered through Lynx's Element PAPI — a contract-complete port of mithril/render/render.js@2.3.8 to the Lynx main thread, packaged as a reusable framework.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/carlos-sweb/mithril-lynx.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/carlos-sweb/mithril-lynx#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/carlos-sweb/mithril-lynx/issues"
|
|
14
|
+
},
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"types": "./src/lynx-mithril-shim.d.ts",
|
|
18
|
+
"default": "./src/lynx-mithril-shim.js"
|
|
19
|
+
},
|
|
20
|
+
"./plugin": {
|
|
21
|
+
"types": "./plugin.d.ts",
|
|
22
|
+
"default": "./plugin.js"
|
|
23
|
+
},
|
|
24
|
+
"./main-thread": {
|
|
25
|
+
"types": "./main-thread.d.ts",
|
|
26
|
+
"default": "./main-thread.js"
|
|
27
|
+
},
|
|
28
|
+
"./background": {
|
|
29
|
+
"types": "./background.d.ts",
|
|
30
|
+
"default": "./background.js"
|
|
31
|
+
},
|
|
32
|
+
"./renderer/main-thread": {
|
|
33
|
+
"types": "./renderer/main-thread.d.ts",
|
|
34
|
+
"default": "./renderer/main-thread.js"
|
|
35
|
+
},
|
|
36
|
+
"./renderer/background": {
|
|
37
|
+
"types": "./renderer/background.d.ts",
|
|
38
|
+
"default": "./renderer/background.js"
|
|
39
|
+
},
|
|
40
|
+
"./element": {
|
|
41
|
+
"types": "./element.d.ts",
|
|
42
|
+
"default": "./element.js"
|
|
43
|
+
},
|
|
44
|
+
"./gesture": {
|
|
45
|
+
"types": "./gesture.d.ts",
|
|
46
|
+
"default": "./gesture.js"
|
|
47
|
+
},
|
|
48
|
+
"./list": {
|
|
49
|
+
"types": "./list.d.ts",
|
|
50
|
+
"default": "./list.js"
|
|
51
|
+
},
|
|
52
|
+
"./testing": {
|
|
53
|
+
"types": "./testing.d.ts",
|
|
54
|
+
"default": "./testing.js"
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
"files": [
|
|
58
|
+
"src",
|
|
59
|
+
"internal",
|
|
60
|
+
"renderer",
|
|
61
|
+
"plugin.js",
|
|
62
|
+
"plugin.d.ts",
|
|
63
|
+
"main-thread.js",
|
|
64
|
+
"main-thread.d.ts",
|
|
65
|
+
"background.js",
|
|
66
|
+
"background.d.ts",
|
|
67
|
+
"element.js",
|
|
68
|
+
"element.d.ts",
|
|
69
|
+
"gesture.js",
|
|
70
|
+
"gesture.d.ts",
|
|
71
|
+
"list.js",
|
|
72
|
+
"list.d.ts",
|
|
73
|
+
"testing.js",
|
|
74
|
+
"testing.d.ts",
|
|
75
|
+
"CONTRACT.md"
|
|
76
|
+
],
|
|
77
|
+
"engines": {
|
|
78
|
+
"node": "^20.19.0 || >=22.12.0"
|
|
79
|
+
},
|
|
80
|
+
"scripts": {
|
|
81
|
+
"test": "rstest run"
|
|
82
|
+
},
|
|
83
|
+
"dependencies": {
|
|
84
|
+
"@lynx-js/runtime-wrapper-webpack-plugin": "^0.2.4",
|
|
85
|
+
"@lynx-js/template-webpack-plugin": "^0.16.0"
|
|
86
|
+
},
|
|
87
|
+
"peerDependencies": {
|
|
88
|
+
"mithril": "2.3.8",
|
|
89
|
+
"@lynx-js/type-element-api": "0.0.9",
|
|
90
|
+
"@lynx-js/types": "4.1.0",
|
|
91
|
+
"@lynx-js/rspeedy": "^0.17.0"
|
|
92
|
+
},
|
|
93
|
+
"devDependencies": {
|
|
94
|
+
"mithril": "2.3.8",
|
|
95
|
+
"@lynx-js/testing-environment": "npm:@lynx-js/testing-environment-canary@0.3.2-canary-20260803-5a891c73",
|
|
96
|
+
"@rstest/core": "^0.11.3",
|
|
97
|
+
"jsdom": "^28.0.0",
|
|
98
|
+
"typescript": "~6.0.3"
|
|
99
|
+
}
|
|
100
|
+
}
|
package/plugin.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Ambient declaration for the ESM plugin.js (the file itself is not
|
|
2
|
+
// type-checked; this describes its runtime export shape for lynx.config.ts).
|
|
3
|
+
|
|
4
|
+
import type { RsbuildPlugin } from "@lynx-js/rspeedy";
|
|
5
|
+
|
|
6
|
+
export interface PluginMithrilLynxOptions {
|
|
7
|
+
/** Lynx engine target SDK version. Defaults to "3.5". */
|
|
8
|
+
targetSdkVersion?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Dual-bundle build plugin: for each configured entry, compiles the entry
|
|
13
|
+
* file as the main-thread/Lepus bundle, and — if a sibling `background.ts`
|
|
14
|
+
* or `background.js` file exists next to it — compiles that as the
|
|
15
|
+
* background/JS-thread bundle. Pure multi-entry bundling, no code transform.
|
|
16
|
+
*/
|
|
17
|
+
export function pluginMithrilLynx(options?: PluginMithrilLynxOptions): RsbuildPlugin;
|