ink-framework 0.1.0-alpha.12 → 0.1.0-alpha.14

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/src/renderer.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  declare const process: { env: { NODE_ENV: string } };
2
- import { createContext, type ReactNode } from "./react";
3
- import Reconciler from "react-reconciler";
4
- import { ConcurrentRoot, DefaultEventPriority, DiscreteEventPriority } from "react-reconciler/constants";
2
+ import { options, render as renderPreact, type Component, type ComponentChildren, type VNode } from "preact";
3
+ import type { ReactNode } from "react";
4
+ import { Activity, activityChanged, isComponentVisible } from "./visibility";
5
5
  import { onNativeMessage } from "./native";
6
6
  import { pressHandler } from "./press";
7
7
  import type { Destination } from "./navigation";
@@ -11,8 +11,74 @@ declare const __inkCommit: (operations: Operation[]) => void;
11
11
  declare const __inkPost: (message: string) => void;
12
12
 
13
13
  type Props = Record<string, unknown>;
14
- type Instance = { id: number; type: string; props: Props; children: Instance[]; parent: Container | undefined; hidden?: boolean };
15
- type Container = { id: number; children: Instance[] };
14
+ const hostProperties = Symbol("ink.hostProps");
15
+ type HostProps = Props & { [hostProperties]?: Props };
16
+ // Preact only needs tree links and mutation methods; no browser DOM is involved.
17
+ class HostNode {
18
+ id: number;
19
+ props: Props = {};
20
+ parentNode: HostNode | null = null;
21
+ firstChild: HostNode | null = null;
22
+ lastChild: HostNode | null = null;
23
+ previousSibling: HostNode | null = null;
24
+ nextSibling: HostNode | null = null;
25
+ hidden = false;
26
+ mounted = false;
27
+ readonly nodeType: number;
28
+ readonly namespaceURI = "http://www.w3.org/1999/xhtml";
29
+ constructor(readonly type: string, text?: string) {
30
+ this.id = type === "#root" ? 0 : allocateHostId();
31
+ this.nodeType = type === "#text" ? 3 : 1;
32
+ if (text !== undefined) this.props = { text: String(text) };
33
+ }
34
+ get localName() { return this.type; }
35
+ get childNodes(): HostNode[] {
36
+ const nodes: HostNode[] = [];
37
+ for (let child = this.firstChild; child; child = child.nextSibling) nodes.push(child);
38
+ return nodes;
39
+ }
40
+ get data() { return this.props.text; }
41
+ set data(text: unknown) {
42
+ this.props = { text: String(text) };
43
+ if (this.mounted) (textChanges ?? createTextBatch()).push(this.id, String(text));
44
+ }
45
+ // Native props and callbacks are read together from the completed host vnode.
46
+ setAttribute() {}
47
+ removeAttribute() {}
48
+ addEventListener() {}
49
+ removeEventListener() {}
50
+ appendChild(child: HostNode) { this.insertBefore(child, null); }
51
+ insertBefore(child: HostNode, before: HostNode | null) {
52
+ if (child === before) return;
53
+ if (child.parentNode) child.parentNode.unlink(child);
54
+ child.parentNode = this;
55
+ child.previousSibling = before ? before.previousSibling : this.lastChild;
56
+ child.nextSibling = before;
57
+ if (child.previousSibling) child.previousSibling.nextSibling = child;
58
+ else this.firstChild = child;
59
+ if (before) before.previousSibling = child;
60
+ else this.lastChild = child;
61
+ if (this.id === 0 || this.mounted) {
62
+ publish(child);
63
+ queue({ op: "insert", id: child.id, parent: this.id, before: before?.id ?? null });
64
+ }
65
+ }
66
+ unlink(child: HostNode) {
67
+ if (child.previousSibling) child.previousSibling.nextSibling = child.nextSibling;
68
+ else this.firstChild = child.nextSibling;
69
+ if (child.nextSibling) child.nextSibling.previousSibling = child.previousSibling;
70
+ else this.lastChild = child.previousSibling;
71
+ child.parentNode = child.previousSibling = child.nextSibling = null;
72
+ }
73
+ removeChild(child: HostNode) {
74
+ this.unlink(child);
75
+ if (child.mounted) {
76
+ queue({ op: "remove", id: child.id, parent: this.id });
77
+ forget(child);
78
+ }
79
+ }
80
+ }
81
+ type Instance = HostNode;
16
82
  type Operation =
17
83
  | { op: "create"; id: number; type: string; props: Props }
18
84
  | { op: "update"; id: number; props: Props }
@@ -26,8 +92,6 @@ let nextId = 1;
26
92
  let operations: Operation[] = [];
27
93
  let textChanges: (number | string)[] | undefined;
28
94
  let commitScheduled = false;
29
- let priority = DefaultEventPriority;
30
- const hostContext = {};
31
95
 
32
96
  function allocateHostId() {
33
97
  if (!Number.isSafeInteger(nextId)) throw new RangeError("Host identifiers exhausted");
@@ -47,7 +111,7 @@ function createTextBatch() {
47
111
 
48
112
  function textContent(type: string, props: Props): string | null {
49
113
  if (type !== "Text") return null;
50
- const value = props.children;
114
+ const value = props.text ?? props.children;
51
115
  return typeof value === "string" ? value : typeof value === "number" || typeof value === "bigint" ? String(value) : null;
52
116
  }
53
117
 
@@ -75,36 +139,21 @@ function nativeProps(props: Props, type: string): Props {
75
139
  }
76
140
 
77
141
  function publish(instance: Instance) {
78
- if (mounted.has(instance.id)) return;
142
+ if (instance.mounted) return;
143
+ instance.mounted = true;
79
144
  mounted.set(instance.id, instance);
80
145
  queue({ op: "create", id: instance.id, type: instance.type, props: nativeProps(instance.props, instance.type) });
81
146
  if (instance.hidden) queue({ op: "hidden", id: instance.id, value: true });
82
- for (const child of instance.children) {
147
+ for (let child = instance.firstChild; child; child = child.nextSibling) {
83
148
  publish(child);
84
149
  queue({ op: "insert", id: child.id, parent: instance.id, before: null });
85
150
  }
86
151
  }
87
152
 
88
- function insert(parent: Container, child: Instance, before?: Instance) {
89
- // Only a moved child needs searching for; appends stay constant-time.
90
- if (child.parent === parent) parent.children.splice(parent.children.indexOf(child), 1);
91
- child.parent = parent;
92
- if (before) parent.children.splice(parent.children.indexOf(before), 0, child);
93
- else parent.children.push(child);
94
- publish(child);
95
- queue({ op: "insert", id: child.id, parent: parent.id, before: before?.id ?? null });
96
- }
97
-
98
153
  function forget(instance: Instance) {
154
+ instance.mounted = false;
99
155
  mounted.delete(instance.id);
100
- instance.parent = undefined;
101
- for (const child of instance.children) forget(child);
102
- }
103
-
104
- function remove(parent: Container, child: Instance) {
105
- parent.children.splice(parent.children.indexOf(child), 1);
106
- queue({ op: "remove", id: child.id, parent: parent.id });
107
- forget(child);
156
+ for (let child = instance.firstChild; child; child = child.nextSibling) forget(child);
108
157
  }
109
158
 
110
159
  function sameListValue(a: unknown, b: unknown): boolean {
@@ -147,9 +196,11 @@ function compiledListUpdate(previous: Props, props: Props): Props | null {
147
196
 
148
197
  function update(instance: Instance, type: string, _oldProps: Props, props: Props) {
149
198
  const previous = instance.props;
199
+ if (previous === props) return;
150
200
  const isText = type === "Text";
151
201
  if (isText) props = hostProps(type, props);
152
202
  instance.props = props;
203
+ if (!instance.mounted) return;
153
204
  if (type === "NativeList" && props.keyField === "key" && previous.template === props.template) {
154
205
  const next = compiledListUpdate(previous, props);
155
206
  if (next) queue({ op: "update", id: instance.id, props: next }, true);
@@ -163,10 +214,11 @@ function update(instance: Instance, type: string, _oldProps: Props, props: Props
163
214
  queue({ op: "update", id: instance.id, props: nativeProps(props, type) });
164
215
  return;
165
216
  }
166
- const children = props.children;
167
- if (children !== previous.children) {
217
+ const children = props.text ?? props.children;
218
+ if (children !== (previous.text ?? previous.children)) {
168
219
  if (typeof children === "string") return (textChanges ?? createTextBatch()).push(instance.id, children);
169
220
  if (typeof children === "number" || typeof children === "bigint") return (textChanges ?? createTextBatch()).push(instance.id, String(children));
221
+ if (textContent(type, previous) !== null) (textChanges ?? createTextBatch()).push(instance.id, "");
170
222
  }
171
223
  return;
172
224
  }
@@ -204,8 +256,9 @@ function update(instance: Instance, type: string, _oldProps: Props, props: Props
204
256
  }
205
257
 
206
258
  function hide(instance: Instance, value: boolean) {
259
+ if (instance.hidden === value) return;
207
260
  instance.hidden = value;
208
- if (mounted.has(instance.id)) queue({ op: "hidden", id: instance.id, value });
261
+ if (instance.mounted) queue({ op: "hidden", id: instance.id, value });
209
262
  }
210
263
 
211
264
  function errorMessage(error: unknown, info: { componentStack?: string | null }) {
@@ -217,118 +270,101 @@ function report(error: unknown, info: { componentStack?: string | null }) {
217
270
  __inkPost(JSON.stringify({ type: "error", message: errorMessage(error, info) }));
218
271
  }
219
272
 
220
- function reportRecovered(error: unknown, info: { componentStack?: string | null }) {
221
- __inkPost(JSON.stringify({ type: "log", level: "error", message: errorMessage(error, info) }));
222
- }
273
+ // These fields are the stable, mangled option-hook ABI in the pinned Preact 10.
274
+ type HostVNode = VNode<Props> & { __e?: HostNode; __?: HostVNode; __k?: (HostVNode | null)[]; __c?: Component };
275
+ const hooks = options as typeof options & {
276
+ __c?: (vnode: HostVNode, queue: unknown[]) => void;
277
+ __inkNative?: boolean;
278
+ __inkHost?: (node: HostNode, type: string, props: Props, vnode: HostVNode) => void;
279
+ };
280
+ hooks.__inkHost = (node, type, props, vnode) => {
281
+ update(node, type, node.props, props);
282
+ if (!node.parentNode && isHidden(vnode)) node.hidden = true;
283
+ };
284
+ const previousDiff = options.diffed;
285
+ options.diffed = vnode => {
286
+ if (typeof vnode.type !== "string" && vnode.type != null) previousDiff?.(vnode);
287
+ const host = vnode as HostVNode;
288
+ if (!hooks.__inkNative) {
289
+ if (typeof host.type === "string" && host.__e) update(host.__e, host.type, host.__e.props, (host.props as HostProps)[hostProperties]!);
290
+ if (host.__e && (typeof host.type === "string" || host.type == null)) hide(host.__e, isHidden(host));
291
+ }
292
+ if (hooks.__inkNative && host.type == null && host.__e) hide(host.__e, isHidden(host));
293
+ if (host.type === Activity && host.__c && activityChanged(host.__c)) visitVisibility(host);
294
+ };
223
295
 
224
- const reconciler = Reconciler<string, Props, Container, Instance, Instance, never, never, never,
225
- Instance, object, never, ReturnType<typeof setTimeout>, -1, null>({
226
- supportsMutation: true,
227
- supportsPersistence: false,
228
- supportsHydration: false,
229
- isPrimaryRenderer: true,
230
- supportsMicrotasks: true,
231
- // Declare `parent` up front so every instance keeps one object shape.
232
- createInstance: (type, props) => ({ id: allocateHostId(), type, props: hostProps(type, props), children: [], parent: undefined }),
233
- createTextInstance: text => ({ id: allocateHostId(), type: "#text", props: { text }, children: [], parent: undefined }),
234
- appendInitialChild: (parent, child) => { parent.children.push(child); child.parent = parent; },
235
- finalizeInitialChildren: () => false,
236
- shouldSetTextContent: (type, props) => {
237
- if (type !== "Text") return false;
238
- const kind = typeof props.children;
239
- return kind === "string" || kind === "number" || kind === "bigint";
240
- },
241
- getRootHostContext: () => hostContext,
242
- getChildHostContext: () => hostContext,
243
- getPublicInstance: instance => instance,
244
- prepareForCommit: () => null,
245
- resetAfterCommit: () => {
246
- if (!operations.length || commitScheduled) return;
247
- commitScheduled = true;
248
- queueMicrotask(() => {
249
- // Revealing an Activity reconnects store subscriptions in passive effects.
250
- // Include their synchronous corrections before presenting the page.
251
- while (reconciler.flushPassiveEffects()) {}
252
- reconciler.flushSyncWork();
253
- const committed = operations;
254
- operations = [];
255
- textChanges = undefined;
256
- commitScheduled = false;
257
- if (committed.length) __inkCommit(committed);
258
- });
259
- },
260
- preparePortalMount: () => {},
261
- scheduleTimeout: setTimeout,
262
- cancelTimeout: clearTimeout,
263
- noTimeout: -1,
264
- scheduleMicrotask: queueMicrotask,
265
- getInstanceFromNode: () => null,
266
- beforeActiveInstanceBlur: () => {},
267
- afterActiveInstanceBlur: () => {},
268
- prepareScopeUpdate: () => {},
269
- getInstanceFromScope: () => null,
270
- detachDeletedInstance: () => {},
271
- appendChild: insert,
272
- appendChildToContainer: insert,
273
- insertBefore: insert,
274
- insertInContainerBefore: insert,
275
- removeChild: remove,
276
- removeChildFromContainer: remove,
277
- resetTextContent: instance => { (textChanges ?? createTextBatch()).push(instance.id, ""); },
278
- commitTextUpdate: (instance, _oldText, text) => {
279
- instance.props = { text };
280
- (textChanges ?? createTextBatch()).push(instance.id, text);
281
- },
282
- commitUpdate: update,
283
- hideInstance: instance => hide(instance, true),
284
- hideTextInstance: instance => hide(instance, true),
285
- unhideInstance: instance => hide(instance, false),
286
- unhideTextInstance: instance => hide(instance, false),
287
- clearContainer: container => {
288
- for (const child of [...container.children]) remove(container, child);
289
- },
290
- NotPendingTransition: null,
291
- // React's public Context type omits the fields consumed by its reconciler.
292
- HostTransitionContext: createContext(null) as unknown as Reconciler.ReactContext<null>,
293
- setCurrentUpdatePriority: value => { priority = value; },
294
- getCurrentUpdatePriority: () => priority,
295
- resolveUpdatePriority: () => priority,
296
- resetFormInstance: () => {},
297
- requestPostPaintCallback: callback => { setTimeout(() => callback(performance.now()), 0); },
298
- shouldAttemptEagerTransition: () => false,
299
- trackSchedulerEvent: () => {},
300
- resolveEventType: () => null,
301
- resolveEventTimeStamp: () => performance.now(),
302
- maySuspendCommit: () => false,
303
- preloadInstance: () => true,
304
- startSuspendingCommit: () => {},
305
- suspendInstance: () => {},
306
- waitForCommitToBeReady: () => null,
307
- });
296
+ function isHidden(vnode: HostVNode) {
297
+ let component: Component | undefined;
298
+ for (let parent = vnode.__; parent; parent = parent.__) {
299
+ // A native ancestor already carries visibility for this entire branch.
300
+ if (typeof parent.type === "string") return false;
301
+ if (parent.__c) {
302
+ component ??= parent.__c;
303
+ if (parent.type === Activity) return !isComponentVisible(parent.__c);
304
+ }
305
+ }
306
+ return component ? !isComponentVisible(component) : false;
307
+ }
308
308
 
309
- export function dispatchEvent(id: number, name: string, args: unknown[]) {
310
- const handler = mounted.get(id)?.props[name];
311
- if (typeof handler !== "function") return;
312
- const previous = priority;
313
- priority = DiscreteEventPriority;
314
- try {
315
- handler(...args);
316
- } finally {
317
- priority = previous;
309
+ function visitVisibility(vnode: HostVNode) {
310
+ if (typeof vnode.type === "string" || vnode.type == null) {
311
+ if (vnode.__e) hide(vnode.__e, isHidden(vnode));
312
+ return;
318
313
  }
314
+ for (const child of vnode.__k ?? []) if (child) visitVisibility(child);
319
315
  }
320
316
 
321
- let developmentRoot: ReturnType<typeof reconciler.createContainer> | undefined;
322
- if (process.env.NODE_ENV === "development") {
323
- reconciler.injectIntoDevTools({ bundleType: 1, version: "19.2.8", rendererPackageName: "ink" });
317
+ const previousVNode = options.vnode;
318
+ options.vnode = vnode => {
319
+ if (typeof vnode.type !== "string") {
320
+ previousVNode?.(vnode);
321
+ return;
322
+ }
323
+ const props = vnode.props as HostProps;
324
+ // Preact reconciles children; Ink compares native props once, after the diff.
325
+ // Keep the original props in this envelope so cloned compiler-cached vnodes
326
+ // retain them without copying or normalising every native property.
327
+ if (!hooks.__inkNative && !props[hostProperties]) {
328
+ (vnode as VNode<HostProps>).props = {
329
+ children: (textContent(vnode.type, props) === null ? props.children : undefined) as ComponentChildren,
330
+ [hostProperties]: props,
331
+ };
332
+ }
333
+ };
334
+
335
+ // QuickJS has no browser paint cycle. Run effects before presenting queued ops.
336
+ options.requestAnimationFrame = callback => { queueMicrotask(callback); };
337
+ options.debounceRendering = queueMicrotask;
338
+ const previousCommit = hooks.__c;
339
+ hooks.__c = (vnode, components) => {
340
+ previousCommit?.(vnode, components);
341
+ if (commitScheduled) return;
342
+ commitScheduled = true;
343
+ queueMicrotask(() => queueMicrotask(() => {
344
+ const committed = operations;
345
+ operations = [];
346
+ textChanges = undefined;
347
+ commitScheduled = false;
348
+ if (committed.length) __inkCommit(committed);
349
+ }));
350
+ };
351
+
352
+ // Preact creates nodes through document, but all layout and painting stay native.
353
+ Object.assign(globalThis, { document: {
354
+ createElementNS: (_namespace: string, type: string) => new HostNode(type),
355
+ createElement: (type: string) => new HostNode(type),
356
+ createTextNode: (text: string) => new HostNode("#text", text),
357
+ } });
358
+
359
+ export function dispatchEvent(id: number, name: string, args: unknown[]) {
360
+ const handler = mounted.get(id)?.props[name];
361
+ if (typeof handler === "function") handler(...args);
324
362
  }
325
363
 
364
+ let developmentRoot: HostNode | undefined;
326
365
  export function render(element: ReactNode) {
327
- if (process.env.NODE_ENV === "development" && developmentRoot) {
328
- const root = developmentRoot;
329
- reconciler.updateContainer(element, root, null, null);
330
- return () => reconciler.updateContainer(null, root, null, null);
331
- }
366
+ const container = process.env.NODE_ENV === "development" && developmentRoot || new HostNode("#root");
367
+ if (process.env.NODE_ENV === "development") developmentRoot = container;
332
368
  onNativeMessage("event", message => {
333
369
  if (typeof message.id !== "number" || !Number.isSafeInteger(message.id)
334
370
  || typeof message.name !== "string" || !Array.isArray(message.args)) {
@@ -336,9 +372,16 @@ export function render(element: ReactNode) {
336
372
  }
337
373
  dispatchEvent(message.id, message.name, message.args);
338
374
  });
339
- const container: Container = { id: 0, children: [] };
340
- const root = reconciler.createContainer(container, ConcurrentRoot, null, false, null, "", report, reportRecovered, reportRecovered, () => {});
341
- if (process.env.NODE_ENV === "development") developmentRoot = root;
342
- reconciler.updateContainer(element, root, null, null);
343
- return () => reconciler.updateContainer(null, root, null, null);
375
+ const updateRoot = () => {
376
+ try {
377
+ renderPreact(element as ComponentChildren, container as unknown as Parameters<typeof renderPreact>[1]);
378
+ } catch (error) {
379
+ report(error, {});
380
+ }
381
+ };
382
+ // Prefresh replaces mounted component types at the end of the bundle.
383
+ // Apply the new root afterwards so matching components keep their state.
384
+ if (process.env.NODE_ENV === "development") queueMicrotask(updateRoot);
385
+ else updateRoot();
386
+ return () => renderPreact(null, container as unknown as Parameters<typeof renderPreact>[1]);
344
387
  }
package/src/reorder.ts CHANGED
@@ -1,14 +1,18 @@
1
1
  import { createElement } from "./react";
2
- import { Button, Stack, Text } from "./index";
3
2
  import { keyboardArrowDown, keyboardArrowUp } from "./icons";
4
3
  import { List } from "./list";
5
- import { nativeListRow, nativeListTemplate } from "./native-list";
4
+ import { fixedListRow } from "./native-list";
6
5
 
7
- const template = /* @__PURE__ */ nativeListTemplate((node, field) => node("Stack", { axis: "horizontal", align: "center", justify: "space-between", gap: 12 }, [
8
- node("Text", { text: field("label"), maxLines: field("maxLines") }),
6
+ type ReorderFields = {
7
+ label: string; maxLines?: number; first: boolean; last: boolean;
8
+ down: () => void; up: () => void;
9
+ };
10
+
11
+ const reorderRow = /* @__PURE__ */ fixedListRow<ReorderFields>((node, field) => node("Stack", { axis: "horizontal", align: "center", justify: "space-between", gap: 12 }, [
12
+ node("Text", { text: field(values => values.label), maxLines: field(values => values.maxLines) }),
9
13
  node("Stack", { axis: "horizontal", align: "center", gap: 4 }, [
10
- node("Button", { icon: keyboardArrowDown, disabled: field("last"), onPress: field("down") }),
11
- node("Button", { icon: keyboardArrowUp, disabled: field("first"), onPress: field("up") }),
14
+ node("Button", { icon: keyboardArrowDown, disabled: field(values => values.last), onPress: field(values => values.down) }),
15
+ node("Button", { icon: keyboardArrowUp, disabled: field(values => values.first), onPress: field(values => values.up) }),
12
16
  ]),
13
17
  ]));
14
18
 
@@ -35,15 +39,8 @@ export function ReorderList<T>({ items, keyExtractor, getLabel, onChange, multil
35
39
  keyExtractor,
36
40
  gap,
37
41
  measurementKey: multiline ? "multiline" : "single-line",
38
- renderItem: nativeListRow((item: T, index: number) => createElement(Stack, {
39
- axis: "horizontal", align: "center", justify: "space-between", gap: 12,
40
- },
41
- createElement(Text, { maxLines: multiline ? undefined : 1 }, getLabel(item)),
42
- createElement(Stack, { axis: "horizontal", align: "center", gap: 4 },
43
- createElement(Button, { icon: keyboardArrowDown, disabled: index === items.length - 1, onPress: () => move(index, 1) }),
44
- createElement(Button, { icon: keyboardArrowUp, disabled: index === 0, onPress: () => move(index, -1) }),
45
- )), (item, index) => [{ label: getLabel(item), maxLines: multiline ? undefined : 1,
42
+ renderItem: reorderRow((item: T, index: number) => ({ label: getLabel(item), maxLines: multiline ? undefined : 1,
46
43
  first: index === 0, last: index === items.length - 1,
47
- down: () => move(index, 1), up: () => move(index, -1) }], template),
44
+ down: () => move(index, 1), up: () => move(index, -1) })),
48
45
  });
49
46
  }