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

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/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "ink-framework",
3
- "version": "0.1.0-alpha.10",
3
+ "version": "0.1.0-alpha.12",
4
4
  "types": "./src/index.ts",
5
5
  "exports": {
6
6
  ".": "./src/index.ts",
7
7
  "./renderer": "./src/renderer.ts",
8
8
  "./internal/list": "./src/native-list.ts",
9
+ "./internal/data": "./src/data-views.ts",
9
10
  "./internal/playback": "./src/playback-binding.ts",
10
11
  "./native": "./src/native.ts",
11
12
  "./native/controller": "./src/controller.ts",
package/src/canvas.ts CHANGED
@@ -15,10 +15,69 @@ export function Rectangle(props: Bounds & {
15
15
  onPress?: () => void;
16
16
  onLongPress?: () => void;
17
17
  onDragEnter?: () => void;
18
+ onDragStart?: () => void;
19
+ onDragEnd?: () => void;
20
+ haptic?: boolean;
18
21
  }) {
19
22
  return createElement("CanvasRectangle", props);
20
23
  }
21
24
 
25
+ /** Native-owned horizontal selection. Remount to reset its initial position. */
26
+ export function CanvasRange({
27
+ total, span, initialStart, step = 1, snapPoints = [], snapDistance = 6, tapSnapDistance = 10, hitSlop = 10,
28
+ minDragWidth = 0, inset = 4, innerFill = "#666666", dragOnly = false, snapOnTap = false, progress = 0,
29
+ progressDurationMs = 0, progressPlaying = false, progressLoop = false, progressFill = "#FFFFFF", ...props
30
+ }: Bounds & {
31
+ total: number;
32
+ span: number;
33
+ initialStart: number;
34
+ step?: number;
35
+ snapPoints?: readonly number[];
36
+ /** Distances and inset use Canvas logical units. Drag release distance is 1.5× entry. */
37
+ snapDistance?: number;
38
+ tapSnapDistance?: number;
39
+ hitSlop?: number;
40
+ /** Minimum selection drag target width; does not change drawing or tap-to-seek. */
41
+ minDragWidth?: number;
42
+ inset?: number;
43
+ fill?: string;
44
+ innerFill?: string;
45
+ /** Move only by dragging the selection; taps inside emit a relative seek. */
46
+ dragOnly?: boolean;
47
+ /** In drag-only mode, marker taps move the selection before considering a seek. */
48
+ snapOnTap?: boolean;
49
+ /** Playback fraction inside the selection, from zero to one. */
50
+ progress?: number;
51
+ /** Enables native playback interpolation; zero keeps progress static. */
52
+ progressDurationMs?: number;
53
+ /** Actual playback advancement, false while paused or buffering. */
54
+ progressPlaying?: boolean;
55
+ progressLoop?: boolean;
56
+ progressFill?: string;
57
+ onRangeSeek?: (position: number) => void;
58
+ onRangeStart?: () => void;
59
+ onRangeCommit?: (start: number) => void;
60
+ /** FIFO snapshot before another press or Back; never emitted for movement. */
61
+ onRangeSnapshot?: (start: number) => void;
62
+ }) {
63
+ return createElement("CanvasRectangle", {
64
+ ...props,
65
+ range: {
66
+ total, span, initialStart, step, snapPoints, snapDistance, tapSnapDistance, hitSlop, minDragWidth, inset,
67
+ innerFill, dragOnly, snapOnTap, progress, progressDurationMs, progressPlaying, progressLoop, progressFill,
68
+ },
69
+ });
70
+ }
71
+
72
+ /** A line through points in canvas units, with round ends and joins. Paths draw above the canvas's rectangles and text. */
73
+ export function CanvasPath({ points, ...props }: {
74
+ points: readonly (readonly [x: number, y: number])[];
75
+ stroke?: string;
76
+ strokeWidth?: number;
77
+ }) {
78
+ return createElement("CanvasPath", { ...props, points: points.flat() });
79
+ }
80
+
22
81
  export function CanvasText({ children, ...props }: Bounds & {
23
82
  children: string | number;
24
83
  size?: number;
@@ -0,0 +1,249 @@
1
+ type IntrinsicCalls = {
2
+ filter<T>(items: readonly T[], predicate: (item: T, index: number) => boolean): T[];
3
+ sort<T>(items: T[], compare: (a: T, b: T) => number): T[];
4
+ map<T, U>(items: readonly T[], transform: (item: T, index: number) => U): U[];
5
+ lower(text: string): string;
6
+ includes(text: string, query: string): boolean;
7
+ localeCompare(a: string, b: string): number;
8
+ dateParse(value: unknown): number;
9
+ jsonParse(source: string, reviver: (this: object, key: string, value: unknown) => unknown): unknown;
10
+ weakMapGet<T>(map: WeakMap<object, T>, key: object): T | undefined;
11
+ weakMapSet<T>(map: WeakMap<object, T>, key: object, value: T): unknown;
12
+ mapGet<K, V>(map: Map<K, V>, key: K): V | undefined;
13
+ mapSet<K, V>(map: Map<K, V>, key: K, value: V): unknown;
14
+ };
15
+
16
+ type Intrinsics = {
17
+ WeakMap: WeakMapConstructor;
18
+ Map: MapConstructor;
19
+ calls?: IntrinsicCalls;
20
+ setTimeout?: (callback: () => void, delay: number) => number;
21
+ clearTimeout?: (timer: number) => void;
22
+ };
23
+
24
+ declare const __inkDataIntrinsics: Intrinsics | undefined;
25
+ declare const __inkDataFields: ((items: unknown, fields: readonly string[], previous: readonly unknown[] | undefined,
26
+ mode: "search" | "sort") => readonly unknown[] | undefined) | undefined;
27
+ declare const __inkDataSearch: ((items: unknown, fields: readonly string[], previousSnapshot: readonly unknown[] | undefined,
28
+ previousText: readonly string[] | undefined, separator: string | null, query?: () => unknown,
29
+ queryLower?: boolean) => (SearchCache & { items?: unknown[]; needle?: string }) | undefined) | undefined;
30
+ declare const __inkPrepareDataSearch: ((items: unknown, fields: readonly string[], separator: string | null,
31
+ cursor: SearchCursor | undefined, limit: number) => SearchCursor | null | undefined) | undefined;
32
+
33
+ const intrinsics = typeof __inkDataIntrinsics === "undefined" ? undefined : __inkDataIntrinsics;
34
+ const calls = intrinsics?.calls;
35
+ const snapshotFields = typeof __inkDataFields === "function" ? __inkDataFields : undefined;
36
+ const prepareSearch = typeof __inkDataSearch === "function" ? __inkDataSearch : undefined;
37
+ const warmSearch = typeof __inkPrepareDataSearch === "function" ? __inkPrepareDataSearch : undefined;
38
+ const schedule = intrinsics?.setTimeout;
39
+ const unschedule = intrinsics?.clearTimeout;
40
+
41
+ type RawPlan = { fields: unknown; separator: unknown; queryLower: unknown; date: unknown; direction: unknown; tie: unknown };
42
+ type SearchCache = { snapshot: readonly unknown[]; text: readonly string[] };
43
+ interface CachedSearch extends SearchCache { changes: number }
44
+ type SearchCursor = SearchCache & { offset: number; complete: boolean };
45
+ type SearchJob = { items: object | undefined; cursor: SearchCursor | undefined; timer: number | undefined };
46
+ type SearchPlan = {
47
+ fields: readonly string[];
48
+ separator: string | null;
49
+ queryLower: boolean;
50
+ cache: WeakMap<object, CachedSearch | undefined>;
51
+ seen: WeakMap<object, boolean>;
52
+ job: SearchJob | undefined;
53
+ retained: boolean;
54
+ next: SearchPlan | undefined;
55
+ };
56
+ type SortPlan = { fields: readonly string[]; direction: 1 | -1; tie: boolean };
57
+ type SortEntry<T> = { item: T; date: number; tie: string };
58
+
59
+ // Plans are compiler literals. Bound retained plans as well as keeping source arrays weak.
60
+ const maximumPlans = 64;
61
+ let searches: Map<string, SearchPlan> | undefined;
62
+ let searchCount = 0;
63
+ let searchHead: SearchPlan | undefined;
64
+ let sorts: Map<string, SortPlan> | undefined;
65
+ let sortCount = 0;
66
+
67
+ function parsePlan(source: string): RawPlan | undefined {
68
+ const i = intrinsics!;
69
+ const records = new i.WeakMap<object, RawPlan>();
70
+ try {
71
+ // Collect only JSON's own members, including when Object.prototype has acquired getters.
72
+ const parsed = calls!.jsonParse(source, function(this: object, key: string, value: unknown) {
73
+ if (key === "fields" || key === "separator" || key === "queryLower" || key === "date" || key === "direction" || key === "tie") {
74
+ let record = calls!.weakMapGet(records, this);
75
+ if (!record) {
76
+ record = { fields: undefined, separator: undefined, queryLower: undefined, date: undefined, direction: undefined, tie: undefined };
77
+ calls!.weakMapSet(records, this, record);
78
+ }
79
+ record[key] = value;
80
+ }
81
+ return value;
82
+ });
83
+ return parsed !== null && typeof parsed === "object"
84
+ ? calls!.weakMapGet(records, parsed) : undefined;
85
+ } catch {
86
+ return undefined;
87
+ }
88
+ }
89
+
90
+ function searchPlan(source: string): SearchPlan | undefined {
91
+ const i = intrinsics!;
92
+ const known = searches && calls!.mapGet(searches, source);
93
+ if (known) return known;
94
+ const raw = parsePlan(source);
95
+ if (!raw || raw.separator !== null && typeof raw.separator !== "string"
96
+ || raw.queryLower !== undefined && typeof raw.queryLower !== "boolean") return undefined;
97
+ // The native guard validates the fields array without invoking getters or Proxy traps.
98
+ const plan: SearchPlan = { fields: raw.fields as readonly string[], separator: raw.separator,
99
+ queryLower: raw.queryLower === true, cache: new i.WeakMap<object, CachedSearch | undefined>(),
100
+ seen: new i.WeakMap<object, boolean>(), job: undefined, retained: true, next: undefined };
101
+ if (!searches || searchCount === maximumPlans) {
102
+ while (searchHead) {
103
+ const evicted = searchHead;
104
+ searchHead = evicted.next;
105
+ evicted.next = undefined;
106
+ evicted.retained = false;
107
+ cancelSearch(evicted);
108
+ }
109
+ searches = new i.Map<string, SearchPlan>();
110
+ searchCount = 0;
111
+ }
112
+ calls!.mapSet(searches, source, plan);
113
+ plan.next = searchHead;
114
+ searchHead = plan;
115
+ searchCount++;
116
+ return plan;
117
+ }
118
+
119
+ function cancelSearch(plan: SearchPlan): void {
120
+ const job = plan.job;
121
+ if (!job) return;
122
+ plan.job = undefined;
123
+ const timer = job.timer;
124
+ job.items = undefined;
125
+ job.cursor = undefined;
126
+ job.timer = undefined;
127
+ if (timer !== undefined) {
128
+ try { unschedule!(timer); } catch { /* Cancellation has already invalidated the job. */ }
129
+ }
130
+ }
131
+
132
+ function disableSearch(plan: SearchPlan, items: object): void {
133
+ calls!.weakMapSet(plan.cache, items, undefined);
134
+ calls!.weakMapSet(plan.seen, items, false);
135
+ }
136
+
137
+ function warmLater(plan: SearchPlan, items: object): void {
138
+ if (!plan.retained || plan.job?.items === items) return;
139
+ cancelSearch(plan);
140
+ const job: SearchJob = { items, cursor: undefined, timer: undefined };
141
+ plan.job = job;
142
+ const step = () => {
143
+ if (plan.job !== job) return;
144
+ job.timer = undefined;
145
+ try {
146
+ const source = job.items!;
147
+ const cursor = warmSearch!(source, plan.fields, plan.separator, job.cursor, 128);
148
+ if (plan.job !== job) return;
149
+ if (!cursor) {
150
+ // Unsupported shapes stay on the original path. A shrinking source (null)
151
+ // and thrown background errors remain eligible for a later attempt.
152
+ if (cursor === undefined) calls!.weakMapSet(plan.seen, source, false);
153
+ cancelSearch(plan);
154
+ } else if (cursor.complete) {
155
+ calls!.weakMapSet(plan.cache, source, { snapshot: cursor.snapshot, text: cursor.text, changes: 0 });
156
+ cancelSearch(plan);
157
+ } else {
158
+ job.cursor = cursor;
159
+ job.timer = schedule!(step, 0);
160
+ }
161
+ } catch {
162
+ // Background work must never turn a successful original search into an error.
163
+ if (plan.job === job) cancelSearch(plan);
164
+ }
165
+ };
166
+ try { job.timer = schedule!(step, 0); } catch { cancelSearch(plan); }
167
+ }
168
+
169
+ function sortPlan(source: string): SortPlan | undefined {
170
+ const i = intrinsics!;
171
+ const known = sorts && calls!.mapGet(sorts, source);
172
+ if (known) return known;
173
+ const raw = parsePlan(source);
174
+ if (!raw || typeof raw.date !== "string" || raw.direction !== 1 && raw.direction !== -1
175
+ || raw.tie !== undefined && typeof raw.tie !== "string") return undefined;
176
+ const plan: SortPlan = { fields: typeof raw.tie === "string" ? [raw.date, raw.tie] : [raw.date],
177
+ direction: raw.direction, tie: typeof raw.tie === "string" };
178
+ if (!sorts || sortCount === maximumPlans) {
179
+ sorts = new i.Map<string, SortPlan>();
180
+ sortCount = 0;
181
+ }
182
+ calls!.mapSet(sorts, source, plan);
183
+ sortCount++;
184
+ return plan;
185
+ }
186
+
187
+ /** @internal The compiler supplies the original filter expression as the fallback. */
188
+ export function compiledSearch<T>(items: readonly T[], source: string, query: () => unknown, fallback: (items: readonly T[]) => T[]): T[] {
189
+ if (!intrinsics || !calls || !prepareSearch || !warmSearch || !schedule || !unschedule) return fallback(items);
190
+ let plan: SearchPlan | undefined;
191
+ try { plan = searchPlan(source); } catch { return fallback(items); }
192
+ if (!plan) return fallback(items);
193
+ if (plan.job && plan.job.items !== items) cancelSearch(plan);
194
+ const before = calls.weakMapGet(plan.cache, items);
195
+ if (!before) {
196
+ const result = fallback(items);
197
+ if (items !== null && (typeof items === "object" || typeof items === "function")) {
198
+ try {
199
+ const seen = calls.weakMapGet(plan.seen, items);
200
+ if (seen === true) warmLater(plan, items);
201
+ else if (seen === undefined) calls.weakMapSet(plan.seen, items, true);
202
+ } catch { /* Retaining a warming candidate is optional. */ }
203
+ }
204
+ return result;
205
+ }
206
+ const prepared = prepareSearch(items, plan.fields, before.snapshot, before.text, plan.separator, query, plan.queryLower);
207
+ if (!prepared) {
208
+ disableSearch(plan, items);
209
+ return fallback(items);
210
+ }
211
+ // The native guard leaves queries unevaluated for rejected or empty sources.
212
+ let result: T[];
213
+ // The guard returns current own row values without converting them.
214
+ if (prepared.items) result = prepared.items as T[];
215
+ else if (typeof prepared.needle === "string") {
216
+ // A query can change descriptors or species after preparation. Use the
217
+ // original filter operation with the already evaluated query in that case.
218
+ const needle = prepared.needle;
219
+ result = calls.filter(items, (_item: T, index: number) => calls.includes(prepared.text[index], needle));
220
+ } else return fallback(items);
221
+ const changes = prepared.snapshot === before.snapshot ? 0 : before.changes + 1;
222
+ if (changes >= 2) disableSearch(plan, items);
223
+ else calls.weakMapSet(plan.cache, items, { snapshot: prepared.snapshot, text: prepared.text, changes });
224
+ return result;
225
+ }
226
+
227
+ /** @internal Sort date keys once per invocation while retaining Array.sort's source identity. */
228
+ export function compiledSort<T>(items: T[], source: string, fallback: (items: T[]) => T[]): T[] {
229
+ if (!intrinsics || !calls || !snapshotFields) return fallback(items);
230
+ const plan = sortPlan(source);
231
+ if (!plan) return fallback(items);
232
+ const snapshot = snapshotFields(items, plan.fields, undefined, "sort");
233
+ if (!snapshot || items.length < 2) return fallback(items);
234
+ const width = plan.fields.length;
235
+ for (let offset = 0; offset < snapshot.length; offset += width) {
236
+ const date = snapshot[offset];
237
+ if (date !== null && typeof date === "object" || typeof date === "function" || typeof date === "symbol"
238
+ || plan.tie && typeof snapshot[offset + 1] !== "string") return fallback(items);
239
+ }
240
+ const entries = calls.map(items, (item: T, index: number): SortEntry<T> => ({ item,
241
+ date: calls.dateParse(snapshot[index * width] ?? "") || 0,
242
+ tie: plan.tie ? snapshot[index * width + 1] as string : "" }));
243
+ calls.sort(entries, (a: SortEntry<T>, b: SortEntry<T>) => {
244
+ const order = plan.direction * (a.date - b.date);
245
+ return order || (plan.tie ? calls.localeCompare(a.tie, b.tie) : 0);
246
+ });
247
+ for (let index = 0; index < entries.length; index++) items[index] = entries[index].item;
248
+ return items;
249
+ }
package/src/image.ts CHANGED
@@ -30,11 +30,25 @@ function RowComponent<const D extends Destination>({ image, title, titleMaxLines
30
30
 
31
31
  export const Row = /* @__PURE__ */ memo(RowComponent) as typeof RowComponent;
32
32
 
33
+ export type ImageCrop = { zoom: number; x: number; y: number };
34
+ export type ImageOverlay = { src: string; x: number; y: number; width: number; height: number };
35
+
33
36
  export function Image(props: {
34
37
  src: string;
35
38
  width: number;
36
39
  height: number;
37
40
  fit?: "contain" | "cover";
41
+ /** Clockwise quarter turns. Reuses one decoded texture across rotation and fit changes. */
42
+ rotation?: 0 | 90 | 180 | 270;
43
+ background?: string;
44
+ /** Normalised centre in the rotated source, with zoom relative to cover (1–4). */
45
+ crop?: ImageCrop;
46
+ /** Native gesture updates, delivered before subsequent press callbacks. */
47
+ onCropChange?: (crop: ImageCrop) => void;
48
+ /** Dashed horizontal guides at normalised frame positions (0–1), independent of crop. */
49
+ horizontalGuides?: readonly number[];
50
+ /** Transparent image in normalised frame coordinates, independent of crop and rotation. */
51
+ overlay?: ImageOverlay;
38
52
  bleed?: boolean;
39
53
  /** Fill the available content width, using width and height as the aspect ratio. */
40
54
  fillWidth?: boolean;
package/src/index.ts CHANGED
@@ -21,7 +21,6 @@ export function Screen({ header, children, leftAction, rightAction, ...props }:
21
21
  centered?: boolean;
22
22
  wide?: boolean;
23
23
  bottomInset?: boolean;
24
- waitForImages?: boolean;
25
24
  leftAction?: { icon: IconAsset; onPress: () => void };
26
25
  rightAction?: { icon: IconAsset; onPress: () => void };
27
26
  }) {
@@ -91,10 +90,11 @@ export function Button<const D extends Destination>(props: PressProps<D> & {
91
90
  }
92
91
 
93
92
  export { Row, Image, Avatar } from "./image";
94
- export { Canvas, Rectangle, CanvasText, CanvasIcon } from "./canvas";
93
+ export { Canvas, Rectangle, CanvasPath, CanvasRange, CanvasText, CanvasIcon } from "./canvas";
95
94
 
96
95
  export function Field<const D extends Destination>(props: PressProps<D> & {
97
96
  label: string;
97
+ disabled?: boolean;
98
98
  children?: ReactNode;
99
99
  }) {
100
100
  const { href, url, onPress, ...rest } = props;
@@ -1,4 +1,4 @@
1
- import { createElement, Fragment, useLayoutEffect, useMemo, useRef, type ComponentProps, type ReactNode } from "./react";
1
+ import { createElement, Fragment, useLayoutEffect, useMemo, useRef, useState, type ComponentProps, type ReactNode } from "./react";
2
2
  import { ListLoadError, ReactList, type ListProps } from "./list";
3
3
  import { useAction } from "./action";
4
4
  import type { Row } from "./image";
@@ -15,6 +15,8 @@ const keys = /* @__PURE__ */ new WeakMap<Function, string>();
15
15
  const plainRecords = /* @__PURE__ */ new WeakSet<object>();
16
16
  declare const __inkIsProxy: (value: unknown) => boolean;
17
17
  const isProxy = typeof __inkIsProxy === "function" ? __inkIsProxy : undefined;
18
+ declare const __inkIsDenseArray: (value: unknown) => boolean;
19
+ const isDenseArray = typeof __inkIsDenseArray === "function" ? __inkIsDenseArray : undefined;
18
20
 
19
21
  function bareFunctionPrototype(value: unknown, owner: Function): value is object {
20
22
  if (value === null || typeof value !== "object" || isProxy?.(value)) return false;
@@ -99,21 +101,73 @@ function nativeValue(value: unknown, previous?: unknown): unknown {
99
101
  }
100
102
 
101
103
  type NativeItem = NativeListItem;
102
- type RowRecord<T> = { item: T; index: number; native: NativeItem };
103
- type Records<T> = { data: NativeItem[]; rows: Map<string, RowRecord<T>>; template: string;
104
+ type Records<T> = { data: NativeItem[]; positions: Map<string, number>; template: string;
104
105
  items: readonly T[]; key: string | Function; measurementKey?: string | number; scope?: Scope };
106
+ const cooperativeListSize = 512;
107
+ const preparationBudget = 4;
108
+ const minimumPreparationRows = 32;
109
+ const maximumPreparationRows = 128;
110
+ type Preparation<T> = { steps: Generator<void, Records<T> | null>; result?: { value: Records<T> | null }; error?: { value: unknown } };
111
+ type Request<T> = { items: readonly T[]; key: string | Function; plan: Plan<T>;
112
+ measurementKey?: string | number; scope?: Scope; preparation: Preparation<T>; synchronous?: { value: Records<T> | null };
113
+ eventPositions: Map<string, number | undefined> };
114
+ type Prepared<T> = { request: Request<T>; generation: number } &
115
+ ({ status: "ready"; value: Records<T> | null } | { status: "error"; error: unknown });
105
116
 
106
- function captureValue(value: unknown, cache: WeakMap<object, Captured>): Captured {
117
+ function listItem<T>(items: readonly T[], index: number): T {
118
+ const property = Object.getOwnPropertyDescriptor(items, index);
119
+ if (!property || !("value" in property)) throw needsReact;
120
+ return property.value;
121
+ }
122
+
123
+ /**
124
+ * Reproject only the items replaced in place, when every key stays at its position.
125
+ * Returns undefined when keys move, so the caller rebuilds the whole list.
126
+ */
127
+ function* replaceInPlace<T>(previous: Records<T>, items: readonly T[], plan: Plan<T>,
128
+ keyExtractor: ListProps<T>["keyExtractor"], dense: boolean): Generator<void, Records<T> | undefined> {
129
+ let data: NativeItem[] | undefined;
130
+ const changes: NativeItem[] = [];
131
+ const length = items.length;
132
+ for (let index = 0; index < length; index++) {
133
+ const item = dense ? items[index] : listItem(items, index);
134
+ if (item === previous.items[index]) continue;
135
+ const before = previous.data[index];
136
+ if (!plainData(item)) throw needsReact;
137
+ if (keyExtractor(item, index) !== before.key) return undefined;
138
+ const projected = nativeValue(plan.project(item, index), before.values);
139
+ if (projected !== before.values) {
140
+ const record = { key: before.key, values: projected, measurementKey: before.measurementKey };
141
+ (data ??= previous.data.slice())[index] = record;
142
+ changes.push(record);
143
+ }
144
+ yield;
145
+ }
146
+ if (data) rememberListPatch(data, { previous: previous.data, changes });
147
+ return { ...previous, data: data ?? previous.data, items };
148
+ }
149
+
150
+ function captureValue(value: unknown, cache: WeakMap<object, Captured>, visiting = new Set<object>()): Captured {
107
151
  if (value === null || typeof value !== "object" && typeof value !== "function") return { value };
108
152
  const previous = cache.get(value);
109
- if (previous) return previous;
153
+ if (previous && isProxy) return previous;
154
+ if (isProxy?.(value) || visiting.has(value) || visiting.size >= 64) throw needsReact;
155
+ const prototype = Object.getPrototypeOf(value);
156
+ if (prototype !== null && prototype !== Object.prototype && prototype !== Array.prototype
157
+ && !(typeof value === "function" && prototype === Function.prototype)) throw needsReact;
158
+ visiting.add(value);
110
159
  const properties: Record<PropertyKey, PropertyDescriptor> = Object.getOwnPropertyDescriptors(value);
111
- const captured: Captured = { value, prototype: Object.getPrototypeOf(value), fields: Reflect.ownKeys(properties).map(key => {
160
+ const fields: NonNullable<Captured["fields"]> = [];
161
+ for (const key of Reflect.ownKeys(properties)) {
112
162
  const property = properties[key];
163
+ if (!("value" in property)) throw needsReact;
164
+ if (["toString", "valueOf", Symbol.toPrimitive].includes(key) && typeof property.value === "function") throw needsReact;
113
165
  const field = key === "prototype" && typeof value === "function" && bareFunctionPrototype(property.value, value)
114
- ? { value: property.value } : captureValue(property.value, cache);
115
- return [key, !!property.enumerable, field];
116
- }) };
166
+ ? { value: property.value } : captureValue(property.value, cache, visiting);
167
+ fields.push([key, !!property.enumerable, field]);
168
+ }
169
+ visiting.delete(value);
170
+ const captured: Captured = { value, prototype, fields };
117
171
  cache.set(value, captured);
118
172
  return captured;
119
173
  }
@@ -135,6 +189,99 @@ function sameScopeValues(before: readonly Captured[], after: readonly Captured[]
135
189
  return before.length === after.length && after.every((value, index) => sameCapture(before[index], value, seen));
136
190
  }
137
191
 
192
+ function captureScope<T>(plan: Plan<T>, items: readonly T[]): Scope | undefined {
193
+ if (!plan.scope) return;
194
+ const captured = items.length ? plan.scope.values() : [];
195
+ // Captures can mutate between slices; validate each fresh snapshot without the row cache.
196
+ return { identity: plan.scope.identity, values: captured.map(value => captureValue(value, new WeakMap())) };
197
+ }
198
+
199
+ function sameScope(before?: Scope, after?: Scope): boolean {
200
+ return !!before && !!after && before.identity === after.identity && sameScopeValues(before.values, after.values);
201
+ }
202
+
203
+ function capturesMatch<T>(request: Request<T>, latestPlan: Plan<T> | null): boolean {
204
+ return !!latestPlan && sameScope(request.scope, captureScope(request.plan, request.items))
205
+ && (latestPlan === request.plan || sameScope(request.scope, captureScope(latestPlan, request.items)));
206
+ }
207
+
208
+ function* prepareRecords<T>(previous: Records<T> | null, items: readonly T[], plan: Plan<T>,
209
+ keyExtractor: ListProps<T>["keyExtractor"], keyIdentity: string | Function,
210
+ measurementKey: string | number | undefined, scope: Scope | undefined, dense: boolean): Generator<void, Records<T> | null> {
211
+ try {
212
+ if (isProxy?.(items) || Object.getPrototypeOf(items) !== Array.prototype) throw needsReact;
213
+ const length = items.length;
214
+ const unchangedScope = sameScope(previous?.scope, scope);
215
+ if (previous && previous.items === items && previous.key === keyIdentity
216
+ && previous.template === plan.template && previous.measurementKey === measurementKey
217
+ && unchangedScope) return previous;
218
+ if (previous && unchangedScope && typeof keyIdentity === "string" && previous.key === keyIdentity
219
+ && previous.template === plan.template && previous.measurementKey === measurementKey
220
+ && previous.items.length === length) {
221
+ const patched = yield* replaceInPlace(previous, items, plan, keyExtractor, dense);
222
+ if (patched !== undefined) return { ...patched, scope };
223
+ }
224
+ const positions = new Map<string, number>();
225
+ let unchanged = length === previous?.data.length;
226
+ let reordered = !unchanged;
227
+ const changes: NativeItem[] = [];
228
+ let data: NativeItem[] = [];
229
+ const itemKeys: string[] = [];
230
+ for (let index = 0; index < length; index++) {
231
+ const item = dense ? items[index] : listItem(items, index);
232
+ if (!plainData(item)) throw needsReact;
233
+ const key = typeof keyIdentity === "string" && previous?.key === keyIdentity && previous.items[index] === item
234
+ ? previous.data[index].key : keyExtractor(item, index);
235
+ if (typeof key !== "string" || positions.has(key)) throw new Error("List keys must be unique strings");
236
+ const beforeIndex = previous?.template === plan.template ? previous.positions.get(key) : undefined;
237
+ const before = previous && beforeIndex !== undefined ? previous.data[beforeIndex] : undefined;
238
+ const projected = previous && beforeIndex !== undefined && unchangedScope && previous.items[beforeIndex] === item
239
+ && (beforeIndex === index || plan.scope?.usesIndex === false)
240
+ ? previous.data[beforeIndex].values : nativeValue(plan.project(item, index), before?.values);
241
+ const measurement = measurementKey ?? null;
242
+ const record = before && before.values === projected && before.measurementKey === measurement
243
+ ? before : { key, values: projected, measurementKey: measurement };
244
+ positions.set(key, index);
245
+ itemKeys.push(key);
246
+ if (record !== previous?.data[index]) {
247
+ unchanged = false;
248
+ if (record !== before) changes.push(record);
249
+ if (key !== previous?.data[index]?.key) reordered = true;
250
+ }
251
+ data.push(record);
252
+ yield;
253
+ }
254
+ if (unchanged && previous) data = previous.data;
255
+ else if (previous) rememberListPatch(data, { previous: previous.data, changes,
256
+ ...(reordered ? { keys: itemKeys } : {}) });
257
+ return { data, positions, template: plan.template, items, key: keyIdentity, measurementKey, scope };
258
+ } catch (error) {
259
+ if (error !== needsReact) throw error;
260
+ return null;
261
+ }
262
+ }
263
+
264
+ function advancePreparation<T>(preparation: Preparation<T>, cooperative: boolean) {
265
+ if (preparation.result) return preparation.result;
266
+ if (preparation.error) throw preparation.error.value;
267
+ const started = cooperative ? performance.now() : 0;
268
+ let rows = 0;
269
+ while (true) {
270
+ let step;
271
+ try {
272
+ step = preparation.steps.next();
273
+ } catch (error) {
274
+ preparation.error = { value: error };
275
+ throw error;
276
+ }
277
+ if (step.done) return preparation.result = { value: step.value };
278
+ rows++;
279
+ // Identity-only sparse scans do not yield: a single replaced row stays synchronous.
280
+ if (cooperative && (rows >= maximumPreparationRows
281
+ || rows >= minimumPreparationRows && performance.now() - started >= preparationBudget)) return;
282
+ }
283
+ }
284
+
138
285
  export function nativeListPlan<T>(render: ListProps<T>["renderItem"]): Plan<T> | undefined {
139
286
  return plans.get(render) as Plan<T> | undefined;
140
287
  }
@@ -145,54 +292,45 @@ export function CompiledList<T>({ plan, ...props }: ListProps<T> & { plan: Plan<
145
292
  const load = useAction(() => onLoadMore?.());
146
293
  const older = useAction(() => onLoadOlder?.());
147
294
  const committed = useRef<Records<T> | null>(null);
148
- const records = useMemo(() => {
149
- const previous = committed.current;
295
+ const committedRequest = useRef<Request<T> | null>(null);
296
+ const committedPlan = useRef<Plan<T> | null>(null);
297
+ const generation = useRef(0);
298
+ const [prepared, setPrepared] = useState<Prepared<T> | null>(null);
299
+ const request = useMemo<Request<T>>(() => {
150
300
  const keyIdentity = keys.get(keyExtractor) ?? keyExtractor;
151
- const captured = plan.scope && (items.length ? plan.scope.values() : []);
152
- if (captured && !captured.every(value => plainData(value, new WeakSet()))) return null;
153
- const snapshots = new WeakMap<object, Captured>();
154
- const scope = plan.scope && { identity: plan.scope.identity, values: captured?.map(value => captureValue(value, snapshots)) ?? [] };
155
- const sameScope = scope && previous?.scope?.identity === scope.identity && sameScopeValues(previous.scope.values, scope.values);
156
- if (previous && previous.items === items && previous.key === keyIdentity
157
- && previous.template === plan.template && previous.measurementKey === measurementKey
158
- && sameScope) return previous;
159
- const rows = new Map<string, RowRecord<T>>();
160
- let unchanged = items.length === previous?.data.length;
161
- let reordered = !unchanged;
162
- const changes: NativeItem[] = [];
163
- let data;
301
+ let scope: Scope | undefined;
164
302
  try {
165
- if (!items.every(item => plainData(item))) throw needsReact;
166
- data = items.map((item, index) => {
167
- const key = typeof keyIdentity === "string" && previous?.key === keyIdentity && previous.items[index] === item
168
- ? previous.data[index].key : keyExtractor(item, index);
169
- if (typeof key !== "string" || rows.has(key)) throw new Error("List keys must be unique strings");
170
- const beforeRow = previous?.template === plan.template ? previous.rows.get(key) : undefined;
171
- const before = beforeRow?.native;
172
- const projected = beforeRow && sameScope && beforeRow.item === item
173
- && (beforeRow.index === index || plan.scope?.usesIndex === false)
174
- ? beforeRow.native.values : nativeValue(plan.project(item, index), before?.values);
175
- const measurement = measurementKey ?? null;
176
- const record = before && before.values === projected && before.measurementKey === measurement
177
- ? before : { key, values: projected, measurementKey: measurement };
178
- rows.set(key, { item, index, native: record });
179
- if (record !== previous?.data[index]) {
180
- unchanged = false;
181
- if (record !== before) changes.push(record);
182
- if (key !== previous?.data[index]?.key) reordered = true;
183
- }
184
- return record;
185
- });
303
+ scope = captureScope(plan, items);
186
304
  } catch (error) {
187
305
  if (error !== needsReact) throw error;
188
- return null;
306
+ const preparation: Preparation<T> = { steps: (function* () { return null; })(), result: { value: null } };
307
+ return { items, key: keyIdentity, plan, measurementKey, preparation, synchronous: preparation.result, eventPositions: new Map() };
189
308
  }
190
- if (unchanged && previous) data = previous.data;
191
- else if (previous) rememberListPatch(data, { previous: previous.data, changes,
192
- ...(reordered ? { keys: data.map(item => item.key) } : {}) });
193
- return { data, rows, template: plan.template, items, key: keyIdentity, measurementKey, scope };
309
+ const pending = committedRequest.current;
310
+ if (pending && pending.items === items && pending.key === keyIdentity
311
+ && pending.plan.template === plan.template && pending.measurementKey === measurementKey
312
+ && sameScope(pending.scope, scope)) return pending;
313
+ const previous = committed.current;
314
+ const dense = isDenseArray?.(items) ?? false;
315
+ const preparation: Preparation<T> = { steps: prepareRecords(previous, items, plan, keyExtractor, keyIdentity, measurementKey, scope, dense) };
316
+ // First population must exist when List's one-shot initialPosition effect scrolls its Screen.
317
+ const cooperative = !!isProxy && !!scope && typeof keyIdentity === "string"
318
+ && !!previous?.items.length && previous.template === plan.template && items.length >= cooperativeListSize;
319
+ const synchronous = advancePreparation(preparation, cooperative);
320
+ return { items, key: keyIdentity, plan, measurementKey, scope, preparation, synchronous, eventPositions: new Map() };
194
321
  }, [items, keyExtractor, plan, measurementKey]);
195
- useLayoutEffect(() => { committed.current = records; }, [records]);
322
+ const outcome = prepared?.request === request && prepared.generation === generation.current ? prepared : undefined;
323
+ let records = outcome?.status === "ready" ? outcome.value
324
+ : request.synchronous ? request.synchronous.value : committed.current;
325
+ if (outcome?.status === "ready" && !request.synchronous) {
326
+ try {
327
+ if (!capturesMatch(request, committedPlan.current)) records = null;
328
+ } catch (error) {
329
+ if (error !== needsReact) throw error;
330
+ records = null;
331
+ }
332
+ }
333
+ const preparing = !request.synchronous && outcome?.status !== "ready";
196
334
  const targets = useMemo(() => {
197
335
  type Template = { id: number; props: Record<string, unknown>; children: Template[] };
198
336
  const targets = new Map<number, Record<string, (string | number)[]>>();
@@ -210,21 +348,88 @@ export function CompiledList<T>({ plan, ...props }: ListProps<T> & { plan: Plan<
210
348
  visit(JSON.parse(plan.template) as Template);
211
349
  return targets;
212
350
  }, [plan.template]);
351
+ const events = useRef<{
352
+ items: readonly T[]; keyExtractor: ListProps<T>["keyExtractor"]; plan: Plan<T>;
353
+ targets: typeof targets; positions: Map<string, number | undefined>;
354
+ } | null>(null);
355
+ useLayoutEffect(() => {
356
+ committed.current = records;
357
+ committedRequest.current = request;
358
+ committedPlan.current = plan;
359
+ events.current = { items, keyExtractor, plan, targets, positions: request.eventPositions };
360
+ return () => {
361
+ events.current = null;
362
+ committedRequest.current = null;
363
+ committedPlan.current = null;
364
+ };
365
+ });
366
+ useLayoutEffect(() => {
367
+ const currentGeneration = ++generation.current;
368
+ let timer: ReturnType<typeof setTimeout> | undefined;
369
+ const current = () => generation.current === currentGeneration && committedRequest.current === request;
370
+ const run = () => {
371
+ if (!current()) return;
372
+ try {
373
+ const result = capturesMatch(request, committedPlan.current)
374
+ ? advancePreparation(request.preparation, true) : { value: null };
375
+ if (!current()) return;
376
+ if (result) setPrepared({ request, generation: currentGeneration, status: "ready", value: result.value });
377
+ else timer = setTimeout(run, 0);
378
+ } catch (error) {
379
+ if (!current()) return;
380
+ setPrepared(error === needsReact
381
+ ? { request, generation: currentGeneration, status: "ready", value: null }
382
+ : { request, generation: currentGeneration, status: "error", error });
383
+ }
384
+ };
385
+ if (!request.synchronous) timer = setTimeout(run, 0);
386
+ return () => {
387
+ generation.current++;
388
+ if (timer !== undefined) clearTimeout(timer);
389
+ };
390
+ }, [request]);
391
+ if (outcome?.status === "error") throw outcome.error;
213
392
  if (!records) return createElement(ReactList<T>, props);
214
393
  const nativeProps = {
215
- items: records.data, keyField: "key", template: plan.template, gap, followEnd, initialEnd: initialPosition === "end",
216
- hasMore: hasMore && load.status !== "pending" && load.status !== "error",
217
- hasOlder: hasOlder && older.status !== "pending" && older.status !== "error",
218
- onEndReached: onLoadMore ? load.run : undefined,
219
- onStartReached: onLoadOlder ? older.run : undefined,
394
+ items: records.data, keyField: "key", template: records.template, gap, followEnd, initialEnd: initialPosition === "end",
395
+ hasMore: !preparing && hasMore && load.status !== "pending" && load.status !== "error",
396
+ hasOlder: !preparing && hasOlder && older.status !== "pending" && older.status !== "error",
397
+ onEndReached: !preparing && onLoadMore ? load.run : undefined,
398
+ onStartReached: !preparing && onLoadOlder ? older.run : undefined,
220
399
  onRowEvent(key: string, target: number, name: string, ...args: unknown[]) {
221
- const path = targets.get(target)?.[name];
222
- const row = records.rows.get(key);
223
- let callback: unknown = row && path ? plan.project(row.item, row.index) : undefined;
224
- for (const part of path ?? []) {
400
+ const latest = events.current;
401
+ const path = latest?.targets.get(target)?.[name];
402
+ if (!latest || !path || isProxy?.(latest.items)) return;
403
+ const matches = (index: number) => {
404
+ const property = Object.getOwnPropertyDescriptor(latest.items, index);
405
+ return !!property && "value" in property && plainData(property.value)
406
+ && latest.keyExtractor(property.value, index) === key;
407
+ };
408
+ let index = latest.items === records.items ? records.positions.get(key) : latest.positions.get(key);
409
+ if (latest.items !== records.items && !latest.positions.has(key)) {
410
+ const beforeIndex = records.positions.get(key);
411
+ if (beforeIndex !== undefined && matches(beforeIndex)) index = beforeIndex;
412
+ else {
413
+ for (let candidate = 0; candidate < latest.items.length; candidate++) {
414
+ if (matches(candidate)) { index = candidate; break; }
415
+ }
416
+ }
417
+ latest.positions.set(key, index);
418
+ }
419
+ if (index === undefined) return;
420
+ let callback: unknown;
421
+ try {
422
+ const item = listItem(latest.items, index);
423
+ if (!plainData(item)) return;
424
+ callback = latest.plan.project(item, index);
425
+ } catch (error) {
426
+ if (error === needsReact) return;
427
+ throw error;
428
+ }
429
+ for (const part of path) {
225
430
  callback = callback != null && typeof callback === "object" ? Reflect.get(callback, part) : undefined;
226
431
  }
227
- if (path && typeof callback === "function") callback(...args);
432
+ if (typeof callback === "function") callback(...args);
228
433
  },
229
434
  };
230
435
  return createElement(Fragment, null,
package/src/renderer.ts CHANGED
@@ -11,7 +11,7 @@ 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[]; hidden?: boolean };
14
+ type Instance = { id: number; type: string; props: Props; children: Instance[]; parent: Container | undefined; hidden?: boolean };
15
15
  type Container = { id: number; children: Instance[] };
16
16
  type Operation =
17
17
  | { op: "create"; id: number; type: string; props: Props }
@@ -86,16 +86,18 @@ function publish(instance: Instance) {
86
86
  }
87
87
 
88
88
  function insert(parent: Container, child: Instance, before?: Instance) {
89
- const previous = parent.children.indexOf(child);
90
- if (previous !== -1) parent.children.splice(previous, 1);
91
- const index = before ? parent.children.indexOf(before) : parent.children.length;
92
- parent.children.splice(index, 0, child);
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);
93
94
  publish(child);
94
95
  queue({ op: "insert", id: child.id, parent: parent.id, before: before?.id ?? null });
95
96
  }
96
97
 
97
98
  function forget(instance: Instance) {
98
99
  mounted.delete(instance.id);
100
+ instance.parent = undefined;
99
101
  for (const child of instance.children) forget(child);
100
102
  }
101
103
 
@@ -226,9 +228,10 @@ const reconciler = Reconciler<string, Props, Container, Instance, Instance, neve
226
228
  supportsHydration: false,
227
229
  isPrimaryRenderer: true,
228
230
  supportsMicrotasks: true,
229
- createInstance: (type, props) => ({ id: allocateHostId(), type, props: hostProps(type, props), children: [] }),
230
- createTextInstance: text => ({ id: allocateHostId(), type: "#text", props: { text }, children: [] }),
231
- appendInitialChild: (parent, child) => { parent.children.push(child); },
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; },
232
235
  finalizeInitialChildren: () => false,
233
236
  shouldSetTextContent: (type, props) => {
234
237
  if (type !== "Text") return false;
package/src/snapshot.ts CHANGED
@@ -13,6 +13,6 @@ export interface SnapshotSource<T> {
13
13
 
14
14
  export function useSnapshot<T>(source: ResourceSource<T>): ResourceSnapshot<T>;
15
15
  export function useSnapshot<T>(source: SnapshotSource<T>): Snapshot<T>;
16
- export function useSnapshot<T>(source: SnapshotSource<T>): Snapshot<T> {
16
+ export function useSnapshot(source: { subscribe(listener: () => void): () => void; getSnapshot(): unknown }): unknown {
17
17
  return useSyncExternalStore(source.subscribe, source.getSnapshot, source.getSnapshot);
18
18
  }