lecodes-sdk 2.1.1 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -35,7 +35,7 @@
35
35
  "prepublishOnly": "bun run build"
36
36
  },
37
37
  "dependencies": {
38
- "@letary/chisel": "^0.13.0"
38
+ "@letary/chisel": "^0.14.0"
39
39
  },
40
40
  "devDependencies": {
41
41
  "@types/bun": "^1.3.14",
@@ -45,7 +45,7 @@
45
45
  "marcidb-embedded": "^0.14.1",
46
46
  "sharp": "^0.35.2"
47
47
  },
48
- "version": "2.1.1",
48
+ "version": "2.2.0",
49
49
  "files": [
50
50
  "src",
51
51
  "dist",
package/prompts/core.md CHANGED
@@ -229,6 +229,7 @@ new WebSocket(url, headers?: Record<string, string>)
229
229
  // ===== STORAGE / DEVICE / MISC =====
230
230
 
231
231
  localStorage.getItem(key): string | null / .setItem(key, value) / .removeItem(key) // nothing else
232
+ signal<Line[]>([]).stored("cart") // a signal kept on the device under the key (JSON values): back on the next launch
232
233
 
233
234
  device.platform: "ios" | "android" | "web" // getter, no ()
234
235
  device.language: string // "en", "ru", …
package/prompts/ui.md CHANGED
@@ -125,17 +125,15 @@ UIScrollable(...children)
125
125
  // visible rows (plus a buffer) are mounted. Use it instead of UIScrollable + map() whenever the item count
126
126
  // is large, grows over time, or is unknown.
127
127
  UIVirtualizedList<T>({
128
+ data: () => T[], // the rows, read reactively: write the signal, the list follows (new array, keys matched)
128
129
  keyOf: (item: T) => string, // STABLE unique id per item (never the array index)
129
130
  render: (item: T) => UINodeChild, // builds one row; called lazily as rows enter the window
130
131
  estimatedHeight: number | ((item: T) => number), // px guess per row (real height measured after mount)
131
132
  overscan?: number, // extra px mounted above/below the viewport (default: one viewport)
132
133
  inverted?: boolean, // true = chat mode: starts scrolled to the end, append at bottom auto-scrolls
133
134
  })
134
- // Data is driven IMPERATIVELY (no diffing) — call these, never re-create the list:
135
- // .setData(items: T[]) // replace everything (diffed by key)
136
- // .append(...items) / .prepend(...items) // variadic; append = newest chat msg; prepend = history, no scroll jump
137
- // .update(...items) // re-render rows with the same keyOf(); unknown keys ignored
138
- // .removeByKey(...keys)
135
+ // Rows added at the end scroll a chat at its bottom; rows added before the first (history) move nothing; a row
136
+ // whose item is a new object re-renders. Never re-create the list.
139
137
  // .itemCount (getter) / .getItem(key)
140
138
  // .scrollTo(offset, animated?) / .scrollToKey(key, animated?) / .scrollToEnd(animated?) / .onScroll(cb(pos))
141
139
  // .onEndReached(thresholdPx: number, cb) // threshold FIRST and required; fires near the BOTTOM — load next page
package/src/chisel.ts CHANGED
@@ -48,6 +48,8 @@ export interface ChiselVocab {
48
48
  signals: {
49
49
  /** Calls that create a signal (the roots of chisel's signal-type inference). */
50
50
  factories: string[]
51
+ /** Methods of a signal that return it (`signal(0).stored("key")`): a call of one on a signal is a signal. */
52
+ chain: string[]
51
53
  /** The property a signal is read through. */
52
54
  value: string
53
55
  }
@@ -131,8 +133,8 @@ export const chiselVocab: ChiselVocab = {
131
133
  "append", "insert", "remove", "setContent", "keepAlive", "url",
132
134
  "detents", "dismissible", "transition", "setSourceRect",
133
135
  "push", "pop", "popToRoot", "replace", "select",
134
- // UITabs / UIVirtualizedList / the native views (NativeView, CameraView, MapView, QRScanner)
135
- "badge", "setData", "update", "prepend", "removeByKey",
136
+ // UITabs / the native views (NativeView, CameraView, MapView, QRScanner)
137
+ "badge", "setData",
136
138
  "on", "off", "onReady", "onError", "onTap", "onMove", "onScan",
137
139
  "flyTo", "jumpTo", "fitPoints", "setPadding", "setUserLocation",
138
140
  ],
@@ -142,6 +144,7 @@ export const chiselVocab: ChiselVocab = {
142
144
  },
143
145
  signals: {
144
146
  factories: [ "signal", "computed" ],
147
+ chain: [ "stored" ],
145
148
  value: "value",
146
149
  },
147
150
  fusion: {
@@ -17,6 +17,8 @@
17
17
  // fallback for engines without it (then stale subscribers just live until the run ends —
18
18
  // every run executes in a fresh JSRuntime anyway).
19
19
 
20
+ import { localStorage } from "../runtime/storage"
21
+
20
22
  interface WeakRefLike<T extends object> { deref(): T | undefined }
21
23
 
22
24
  const WeakRefImpl: new <T extends object>(target: T) => WeakRefLike<T> =
@@ -150,6 +152,12 @@ export interface Signal<T> {
150
152
  value: T,
151
153
  /** Read without subscribing the current effect/computed. */
152
154
  peek(): T,
155
+ /**
156
+ * Kept on the device under `key`: what the last run left there replaces the initial value (when
157
+ * it is there and reads as JSON), and every write is saved. JSON values only — a date, a class
158
+ * instance or a function does not survive the round trip. `signal<Line[]>([]).stored("cart")`.
159
+ */
160
+ stored(key: string): Signal<T>,
153
161
  }
154
162
 
155
163
  export interface Computed<T> {
@@ -161,6 +169,7 @@ export interface Computed<T> {
161
169
  class SignalImpl<T> {
162
170
  private _dep = new Dep()
163
171
  private _v: T
172
+ private _key?: string
164
173
 
165
174
  constructor(value: T) {
166
175
  this._v = value
@@ -174,12 +183,31 @@ class SignalImpl<T> {
174
183
  set value(next: T) {
175
184
  if (Object.is(next, this._v)) return
176
185
  this._v = next
186
+ if (this._key !== undefined) this._save()
177
187
  this._dep._notify()
178
188
  }
179
189
 
180
190
  peek(): T {
181
191
  return this._v
182
192
  }
193
+
194
+ stored(key: string): Signal<T> {
195
+ this._key = key
196
+ let raw: string | null = null
197
+ try { raw = localStorage.getItem(key) } catch { /* a host without storage: the value lives for this run */ }
198
+ if (raw !== null) {
199
+ try { this.value = JSON.parse(raw) as T } catch { /* not what this app wrote: the initial value stands */ }
200
+ }
201
+ return this
202
+ }
203
+
204
+ // Saved as it is written, so what the device keeps never lags behind the screen (no effect, no flush).
205
+ private _save(): void {
206
+ try {
207
+ if (this._v === undefined) localStorage.removeItem(this._key!)
208
+ else localStorage.setItem(this._key!, JSON.stringify(this._v))
209
+ } catch { /* storage-less host, or a value JSON cannot carry: the screen is right, the device forgets */ }
210
+ }
183
211
  }
184
212
 
185
213
  class ComputedImpl<T> implements Computation {
package/src/inject.ts CHANGED
@@ -43,6 +43,8 @@ export { Net, NetPlayer, Replicated, NetEntity, type NetMessage, type NetRole, t
43
43
  // App-backend transport: the compiler-generated stubs of `*.server.ts` modules call these; user code
44
44
  // only ever imports its server functions (docs/backend-plan.md §4).
45
45
  export { __rpc, __channel, __serverOnly, RpcError } from "./runtime/rpc"
46
+ // What a screen asks of the server, as reactive state: a request a screen follows, an action with its own busy / error.
47
+ export { resource, action, dataOf, failureOf, type Loaded, type Failure, type Resource, type Action } from "./runtime/resource"
46
48
  export type { ChannelSubscription } from "./server/channel"
47
49
  export type { StoredFile, StoredImage } from "./server/files/models"
48
50
  export { AudioPlayer, VideoPlayer } from "./runtime/media"
@@ -0,0 +1,96 @@
1
+ // What a screen asks of the app's server, as reactive state: a `resource` is a request a screen
2
+ // follows (loading, ready, failed — ONE value), an `action` is a call with its own busy / error —
3
+ // no try around a call, no flags by hand. Both take the compiled server stubs of ./rpc.ts, so a
4
+ // failure is an RpcError: its status is the server's ApiError's, 0 when the request never reached
5
+ // a server (no network, no backend) — `offline`.
6
+
7
+ import { effect, signal, type Computed } from "../core/signals"
8
+
9
+ /** Why a call failed: the server's refusal in its words — or `offline`, the request never reached
10
+ * it (then `message` is the transport's note, not something to show; say it in your own words). */
11
+ export type Failure = { readonly message: string, readonly offline: boolean }
12
+
13
+ /** What was asked of the server, at any moment: one value a screen draws in its three states. */
14
+ export type Loaded<T> =
15
+ | { readonly state: "loading" }
16
+ | { readonly state: "ready", readonly data: T }
17
+ | ({ readonly state: "failed" } & Failure)
18
+
19
+ /** The data once it is there; `otherwise` until then. */
20
+ export const dataOf = <T>(loaded: Loaded<T>, otherwise: T): T => loaded.state === "ready" ? loaded.data : otherwise
21
+
22
+ /** The failure, or null while there is none. */
23
+ export const failureOf = <T>(loaded: Loaded<T>): Failure | null => loaded.state === "failed" ? loaded : null
24
+
25
+ const failure = (error: unknown): Failure => ({
26
+ message: error instanceof Error ? error.message : String(error),
27
+ offline: (error as { status?: unknown } | null)?.status === 0,
28
+ })
29
+
30
+ export interface Resource<T> {
31
+ /** The request now — read in an effect, a computed or a binding, the screen follows it. */
32
+ readonly value: Loaded<T>
33
+ /** Read without subscribing. */
34
+ peek(): Loaded<T>
35
+ /** Ask again. What is shown stays until the answer. */
36
+ reload(): void
37
+ }
38
+
39
+ /**
40
+ * What `load` answers, as a `Loaded` signal: asked at once, again when a signal `load` read changes
41
+ * and on `reload()`. An answer that arrives after a newer request was made is dropped. While the
42
+ * data is asked for again, what is there stays. With `initial` — rows the bundle carries — the data
43
+ * is ready from the start and the server's answer replaces it; a failure then leaves it as it is,
44
+ * since what is shown is real.
45
+ */
46
+ export function resource<T>(load: () => Promise<T>, options: { initial?: T } = {}): Resource<T> {
47
+ const loaded = signal<Loaded<T>>(options.initial !== undefined ? { state: "ready", data: options.initial } : { state: "loading" })
48
+ const asked = signal(0)
49
+ let latest = 0
50
+ effect(() => {
51
+ asked.value
52
+ const mine = ++latest
53
+ let request: Promise<T>
54
+ try { request = load() } catch (error) { request = Promise.reject(error) }
55
+ if (loaded.peek().state !== "ready") loaded.value = { state: "loading" }
56
+ request.then(
57
+ (data) => { if (mine === latest) loaded.value = { state: "ready", data } },
58
+ (error) => { if (mine === latest && !(options.initial !== undefined && loaded.peek().state === "ready")) loaded.value = { state: "failed", ...failure(error) } },
59
+ )
60
+ })
61
+ return {
62
+ get value() { return loaded.value },
63
+ peek: () => loaded.peek(),
64
+ reload: () => { asked.value = asked.peek() + 1 },
65
+ }
66
+ }
67
+
68
+ export type Action<A extends unknown[], R> = ((...args: A) => Promise<R | null>) & {
69
+ /** True while a call runs; a call made meanwhile is ignored (it resolves to null). */
70
+ readonly busy: Computed<boolean>
71
+ /** Why the last call failed; null when it did not. Cleared as a call starts. */
72
+ readonly error: Computed<Failure | null>
73
+ }
74
+
75
+ /**
76
+ * Something the person does that the server must carry out. Resolves to what the server answered,
77
+ * or null when the call failed (then `error` says why) or was ignored while busy.
78
+ */
79
+ export function action<A extends unknown[], R>(run: (...args: A) => Promise<R>): Action<A, R> {
80
+ const busy = signal(false)
81
+ const error = signal<Failure | null>(null)
82
+ const call = async (...args: A): Promise<R | null> => {
83
+ if (busy.peek()) return null
84
+ busy.value = true
85
+ error.value = null
86
+ try {
87
+ return await run(...args)
88
+ } catch (thrown) {
89
+ error.value = failure(thrown)
90
+ return null
91
+ } finally {
92
+ busy.value = false
93
+ }
94
+ }
95
+ return Object.assign(call, { busy, error })
96
+ }
@@ -258,6 +258,8 @@ export interface Query<T extends ModelTypes, Sel = T["scalars"]> extends Promise
258
258
  aggregate<A extends T["aggregate"]>(query: A): Op<AggregateResult<T["model"], A>> & Sub<T["name"], NoInfer<A>>
259
259
 
260
260
  insert(data: T["insert"]): Op<T["id"]>
261
+ /** The new row by `select`'s shape — its defaults, dates and files as stored: one call, nothing to read back. */
262
+ insert<S extends T["query"]>(data: T["insert"], options: { select: S }): Promise<Rows<T, S>>
261
263
  update(id: IdArg<T["id"]>, data: T["update"]): Op<void>
262
264
  /** Applies `data` to every row the chain's `where` matches (all rows without one); resolves to the number of rows. */
263
265
  updateMany(data: T["update"]): Op<number>
@@ -454,7 +456,13 @@ export function createQueryLayer(options: QueryLayerOptions): { op: (descriptor:
454
456
  return Object.assign(op({ model, action: "aggregate", query: q }), { __select: q });
455
457
  },
456
458
 
457
- insert: (data: any) => op({ model, action: "insert", data: options.prepareInsert ? options.prepareInsert(model, data) : data }),
459
+ insert: (data: any, extra?: { select?: Record<string, any> }) => {
460
+ const inserted = op({ model, action: "insert", data: options.prepareInsert ? options.prepareInsert(model, data) : data });
461
+ if (!extra?.select) return inserted;
462
+ // the row as stored, read back by the id the engine answered — a plain promise: a read is not a $transaction member
463
+ return inserted.then((id: any) => run({ model, action: "findFirst", query: resolveShape(model, { ...extra.select, $where: id }) }))
464
+ .then((row: any) => { if (row === null) throw new Error(`marcidb: ${model}: the row just inserted is gone`); return row; });
465
+ },
458
466
  update: (id: any, data: any) => op({ model, action: "update", id: idObject(model, id), data }),
459
467
  // `updateMany(data)`; the deprecated `updateMany(query, data)` form is told apart by arity
460
468
  updateMany: (a: any, b?: any) => (b === undefined
@@ -9,7 +9,7 @@
9
9
 
10
10
  import type { Field, FieldMeta } from "./fields"
11
11
  import type { IdentityPublicFields, SessionPublicFields } from "../auth/models"
12
- import type { FileField, StoredFile, UploadedFile as File } from "../files/models"
12
+ import type { FileField, FileWrite, StoredFile } from "../files/models"
13
13
  import type {
14
14
  CompareNumValue, CompareRefListValue, CompareRefValue, CompareStrValue, CompareValue, CustomSearchValue,
15
15
  FullTextSearch, JsonCondition, JsonPathWhere, Op, PrimitiveListUpdate, Query as MarciQuery, RefListUpdate,
@@ -72,7 +72,7 @@ type FieldInsert<S, Fld, M extends FieldMeta = MetaOf<Fld>> =
72
72
  M["kind"] extends "one" ? Nullable<M, Id<S, ModelName<S, Fld>>>
73
73
  : M["kind"] extends "many" | "list" ? Id<S, ModelName<S, Fld>>[]
74
74
  : M["kind"] extends "struct" ? Nullable<M, StructInsert<S, StructFieldsOf<Fld>>>
75
- : M["kind"] extends "file" ? Nullable<M, Arr<M, File>> // a new row takes uploads
75
+ : M["kind"] extends "file" ? Nullable<M, Arr<M, FileWrite>> // a new row takes uploads
76
76
  : Nullable<M, Arr<M, TsOf<Fld>>>
77
77
 
78
78
  type RequiredKeys<F extends Fields> = {
@@ -99,7 +99,7 @@ type FieldUpdate<S, Fld, M extends FieldMeta = MetaOf<Fld>> =
99
99
  : M["kind"] extends "list" ? RefListUpdateOrdered<Id<S, ModelName<S, Fld>>>
100
100
  : M["kind"] extends "struct" ? RefUpdateStruct<StructInsert<S, StructFieldsOf<Fld>>, UpdateOf<S, StructFieldsOf<Fld>>> | null
101
101
  // an upload replaces; the stored file the field holds, handed back, keeps it; null / a shorter list deletes
102
- : M["kind"] extends "file" ? Nullable<M, Arr<M, File | StoredFile>>
102
+ : M["kind"] extends "file" ? Nullable<M, Arr<M, FileWrite | StoredFile>>
103
103
  : M["array"] extends true ? TsOf<Fld>[] | PrimitiveListUpdate<TsOf<Fld>>
104
104
  : Nullable<M, TsOf<Fld> | (IsNum<M> extends true ? UpdateNumValue : never)>
105
105
 
@@ -43,6 +43,11 @@ export type StoredImage = StoredFile & { width: number, height: number }
43
43
  */
44
44
  export type UploadedFile = { readonly name: string, readonly size: number, readonly type: string }
45
45
 
46
+ /** What a file field is written with. A project's own type check sees the endpoint's `File` as the
47
+ * app's (global.d.ts: a name and a size — the type is read from the bytes on arrival), so the write
48
+ * asks for no more; what arrives is an `UploadedFile`. */
49
+ export type FileWrite = { readonly name: string, readonly size: number }
50
+
46
51
  /** One `t.file()` field of the schema; `image` = it is a `t.image()`, with its rule. */
47
52
  export type FileField = { model: string, key: string, array: boolean, owner: string, image?: ImageRule }
48
53
 
@@ -1,3 +1,4 @@
1
+ import { createBinding } from "../core/signals"
1
2
  import { Element, type Color, type DrawableStyle, type ElementStyle, type UIElementBase, type UINodeChild } from "./UINode"
2
3
  import { TREE_FLAG_REFRESH, TREE_FLAG_SCROLL, TREE_FLAG_VLIST_END, TREE_FLAG_VLIST_START, pin, tree, unpin } from "./tree"
3
4
 
@@ -10,6 +11,11 @@ export type UIVirtualizedListStyle = DrawableStyle & ElementStyle & {
10
11
  }
11
12
 
12
13
  export interface VListConfig<T> {
14
+ /** The rows, read reactively: the list follows the signals this reads, the way a container's
15
+ * function children do. Rows are told apart by `keyOf`; a row whose item is another object
16
+ * re-renders. Appended rows and rows added at the start are told from a rewrite (a chat at
17
+ * its bottom follows what is appended; older history above moves nothing). */
18
+ data: () => T[]
13
19
  /** Stable unique key of an item. */
14
20
  keyOf: (item: T) => string
15
21
  /** Subtree factory — called when the item enters the window; the subtree lives until it
@@ -27,22 +33,10 @@ export interface VListConfig<T> {
27
33
  }
28
34
 
29
35
  /** A windowed (virtualized) list: only the items near the viewport are mounted — for long feeds
30
- * and chats. Items are keyed; feed it via `setData`/`append`/`prepend`. */
36
+ * and chats. Fed by `data`, a function over signals. */
31
37
  export interface UIVirtualizedList<T = unknown> extends UIElementBase<UIVirtualizedListStyle> {
32
38
  readonly type: "vlist"
33
39
 
34
- /** Replace the data wholesale — vanished keys are unmounted by the window sync. */
35
- setData(items: T[]): this
36
- /** Append items to the end (auto-scrolls when `inverted` and resting at the bottom). */
37
- append(...items: T[]): this
38
- /** Insert at the start WITHOUT a scroll jump (the runtime compensates the offset). */
39
- prepend(...items: T[]): this
40
- /** Update items with the same keys: mounted ones re-render; the cached height persists until
41
- * re-measured. */
42
- update(...items: T[]): this
43
- /** Remove items by key. */
44
- removeByKey(...keys: string[]): this
45
-
46
40
  /** Number of data items (mounted or not). */
47
41
  readonly itemCount: number
48
42
  /** The data item under `key`, if any. */
@@ -72,6 +66,7 @@ export class VListElement<T> extends Element<"vlist"> implements UIVirtualizedLi
72
66
  private readonly strl: (() => void)[] = [] // startReached
73
67
  private _refreshCallback?: () => void | Promise<void>
74
68
 
69
+ private keys: string[] = []
75
70
  private items = new Map<string, T>()
76
71
  private mounted = new Map<string, any>()
77
72
  private keyOf: (item: T) => string
@@ -95,77 +90,61 @@ export class VListElement<T> extends Element<"vlist"> implements UIVirtualizedLi
95
90
  : () => config.estimatedHeight as number
96
91
  this._overscan = config.overscan ?? -1
97
92
  this._inverted = config.inverted ?? false
93
+ // The data binding is the element's (it goes with it); its first run waits a microtask so the
94
+ // edge thresholds chained after the constructor (`.onEndReached(…)`) are in the runtime's list
95
+ // state, which is created on the first write.
96
+ Promise.resolve().then(() => { if (this._h.id !== 0) createBinding(this, "data", () => this._apply(config.data())) })
98
97
  }
99
98
 
100
99
  // The runtime's list state is created on the first data write — after the app had its chance to
101
- // set the edge thresholds (onEndReached / onStartReached come before setData in practice).
100
+ // set the edge thresholds.
102
101
  private _ensure(): void {
103
102
  if (this._inited || this._h.id === 0) return
104
103
  this._inited = true
105
104
  tree().vlistInit(this._h.id, this._overscan, this._inverted, this._startThreshold, this._endThreshold)
106
105
  }
107
106
 
108
- private register(items: T[]): [string[], number[]] {
107
+ /** The data as it is now against what the runtime holds: rows added at the end or at the start
108
+ * are inserts (the runtime anchors the scroll: a chat follows an append, history above moves
109
+ * nothing), rows gone are removals, anything else a rewrite of the keys (measured heights stay). */
110
+ private _apply(next: T[]): void {
111
+ const prevKeys = this.keys
112
+ const prevItems = this.items
109
113
  const keys: string[] = []
110
114
  const estimates: number[] = []
111
- for (const item of items) {
115
+ const items = new Map<string, T>()
116
+ for (const item of next) {
112
117
  const k = this.keyOf(item)
113
- this.items.set(k, item)
118
+ if (items.has(k)) continue // a repeated key: the first row stands
119
+ items.set(k, item)
114
120
  keys.push(k)
115
121
  estimates.push(this.estimate(item))
116
122
  }
117
- return [keys, estimates]
118
- }
119
-
120
- setData(items: T[]): this {
121
- const prevItems = new Map(this.items)
122
- const prevMounted = [...this.mounted.keys()]
123
- this.items.clear()
124
- const [keys, estimates] = this.register(items)
125
- this._ensure()
126
- if (this._h.id !== 0) {
127
- tree().vlistSetKeys(this._h.id, keys, estimates)
128
- for (const k of prevMounted) {
129
- if (this.mounted.has(k) && this.items.get(k) !== prevItems.get(k)) {
130
- tree().vlistInvalidate(this._h.id, k)
131
- }
132
- }
133
- }
134
- return this
135
- }
136
-
137
- append(...items: T[]): this {
138
- const [keys, estimates] = this.register(items)
139
- this._ensure()
140
- if (this._h.id !== 0) tree().vlistInsertKeys(this._h.id, -1, keys, estimates)
141
- return this
142
- }
143
-
144
- prepend(...items: T[]): this {
145
- const [keys, estimates] = this.register(items)
123
+ this.keys = keys
124
+ this.items = items
146
125
  this._ensure()
147
- if (this._h.id !== 0) tree().vlistInsertKeys(this._h.id, 0, keys, estimates)
148
- return this
149
- }
150
-
151
- update(...items: T[]): this {
152
- for (const item of items) {
153
- const k = this.keyOf(item)
154
- if (!this.items.has(k)) continue
155
- this.items.set(k, item)
156
- if (this._h.id !== 0 && this.mounted.has(k)) tree().vlistInvalidate(this._h.id, k)
126
+ const id = this._h.id
127
+ if (id === 0) return
128
+ const n = prevKeys.length, m = keys.length
129
+ const same = (from: number) => { for (let i = 0; i < n; i++) if (keys[from + i] !== prevKeys[i]) return false; return true }
130
+ if (n === 0 || m === 0) {
131
+ tree().vlistSetKeys(id, keys, estimates)
132
+ } else if (m >= n && same(0)) {
133
+ if (m > n) tree().vlistInsertKeys(id, -1, keys.slice(n), estimates.slice(n)) // the same keys: nothing to tell
134
+ } else if (m > n && same(m - n)) {
135
+ tree().vlistInsertKeys(id, 0, keys.slice(0, m - n), estimates.slice(0, m - n))
136
+ } else {
137
+ // removals only: the new keys are the old ones in order, some left out
138
+ const gone: string[] = []
139
+ let j = 0
140
+ for (const k of prevKeys) { if (j < m && keys[j] === k) j++; else gone.push(k) }
141
+ if (m < n && j === m) tree().vlistRemoveKeys(id, gone)
142
+ else tree().vlistSetKeys(id, keys, estimates)
157
143
  }
158
- return this
159
- }
160
-
161
- removeByKey(...keys: string[]): this {
162
- const gone: string[] = []
163
- for (const k of keys) {
164
- if (!this.items.delete(k)) continue
165
- gone.push(k)
144
+ // a mounted row whose item is another object re-renders; the others pick theirs up when they re-enter
145
+ for (const k of this.mounted.keys()) {
146
+ if (items.has(k) && prevItems.has(k) && items.get(k) !== prevItems.get(k)) tree().vlistInvalidate(id, k)
166
147
  }
167
- if (this._h.id !== 0 && gone.length > 0) tree().vlistRemoveKeys(this._h.id, gone)
168
- return this
169
148
  }
170
149
 
171
150
  get itemCount(): number { return this.items.size }
@@ -233,8 +212,8 @@ export class VListElement<T> extends Element<"vlist"> implements UIVirtualizedLi
233
212
  }
234
213
  }
235
214
 
236
- /** Create a virtualized list from a config (`keyOf` + `render` + `estimatedHeight` required),
237
- * then feed it via `setData`/`append`/`prepend`. Give it a size (`flexGrow: 1` as a screen body). */
215
+ /** Create a virtualized list from a config (`data` + `keyOf` + `render` + `estimatedHeight`
216
+ * required). Give it a size (`flexGrow: 1` as a screen body). */
238
217
  export function UIVirtualizedList<T>(config: VListConfig<T>): UIVirtualizedList<T> {
239
218
  return new VListElement<T>(config)
240
219
  }
package/src/version.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  // same manifest). Its MAJOR is the bundle ↔ runtime contract: a bundle's `// sdk:` header line
5
5
  // and a host's embedded version must agree on the major for the host to run the bundle; minor
6
6
  // and patch never gate anything (features are detected, never versioned).
7
- export const SDK_VERSION = "2.1.1"
7
+ export const SDK_VERSION = "2.2.0"
8
8
 
9
9
  /** The major of a semver string, or null when it is not one. */
10
10
  export const sdkMajor = (version: string | null | undefined): number | null => {