@b9g/crank 0.7.0 → 0.7.2

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/crank.d.ts CHANGED
@@ -193,17 +193,81 @@ declare class Retainer<TNode, TScope = unknown> {
193
193
  constructor(el: Element);
194
194
  }
195
195
  /**
196
- * Interface for adapting the rendering process to a specific target
197
- * environment. This interface is implemented by Renderer subclasses and passed
198
- * to the Renderer constructor.
196
+ * Interface for adapting the rendering process to a specific target environment.
197
+ *
198
+ * The RenderAdapter defines how Crank elements are mapped to nodes in your target
199
+ * rendering environment (DOM, Canvas, WebGL, Terminal, etc.). Each method handles
200
+ * a specific part of the element lifecycle, from creation to removal.
201
+ *
202
+ * @template TNode - The type representing a node in your target environment
203
+ * @template TScope - Additional context data passed down the component tree
204
+ * @template TRoot - The type of the root container (defaults to TNode)
205
+ * @template TResult - The type returned when reading element values (defaults to ElementValue<TNode>)
206
+ *
207
+ * @example
208
+ * ```typescript
209
+ * const adapter: RenderAdapter<MyNode, MyScope> = {
210
+ * create: ({ tag, props }) => new MyNode(tag, props),
211
+ * patch: ({ node, props }) => node.update(props),
212
+ * arrange: ({ node, children }) => node.replaceChildren(children),
213
+ * // ... other methods
214
+ * };
215
+ * ```
199
216
  */
200
217
  export interface RenderAdapter<TNode, TScope, TRoot extends TNode | undefined = TNode, TResult = ElementValue<TNode>> {
218
+ /**
219
+ * Creates a new node for the given element tag and props.
220
+ *
221
+ * This method is called when Crank encounters a new element that needs to be
222
+ * rendered for the first time. You should create and return a node appropriate
223
+ * for your target environment.
224
+ *
225
+ * @param data.tag - The element tag (e.g., "div", "sprite", or a symbol)
226
+ * @param data.tagName - String representation of the tag for debugging
227
+ * @param data.props - The element's props object
228
+ * @param data.scope - Current scope context (can be undefined)
229
+ * @returns A new node instance
230
+ *
231
+ * @example
232
+ * ```typescript
233
+ * create: ({ tag, props, scope }) => {
234
+ * if (tag === "sprite") {
235
+ * return new PIXI.Sprite(props.texture);
236
+ * }
237
+ * throw new Error(`Unknown tag: ${tag}`);
238
+ * }
239
+ * ```
240
+ */
201
241
  create(data: {
202
242
  tag: string | symbol;
203
243
  tagName: string;
204
244
  props: Record<string, any>;
205
245
  scope: TScope | undefined;
206
246
  }): TNode;
247
+ /**
248
+ * Adopts existing nodes during hydration.
249
+ *
250
+ * Called when hydrating server-rendered content or reusing existing nodes.
251
+ * Should return an array of child nodes if the provided node matches the
252
+ * expected tag, or undefined if hydration should fail.
253
+ *
254
+ * @param data.tag - The element tag being hydrated
255
+ * @param data.tagName - String representation of the tag
256
+ * @param data.props - The element's props
257
+ * @param data.node - The existing node to potentially adopt
258
+ * @param data.scope - Current scope context
259
+ * @returns Array of child nodes to hydrate, or undefined if adoption fails
260
+ *
261
+ * @example
262
+ * ```typescript
263
+ * adopt: ({ tag, node }) => {
264
+ * if (node && node.tagName.toLowerCase() === tag) {
265
+ * return Array.from(node.children);
266
+ * }
267
+ * return undefined; // Hydration mismatch
268
+ * }
269
+ * ```
270
+ */
207
271
  adopt(data: {
208
272
  tag: string | symbol;
209
273
  tagName: string;
@@ -211,23 +275,124 @@ export interface RenderAdapter<TNode, TScope, TRoot extends TNode | undefined =
211
275
  node: TNode | undefined;
212
276
  scope: TScope | undefined;
213
277
  }): Array<TNode> | undefined;
278
+ /**
279
+ * Creates or updates a text node.
280
+ *
281
+ * Called when rendering text content. Should create a new text node or
282
+ * update an existing one with the provided value.
283
+ *
284
+ * @param data.value - The text content to render
285
+ * @param data.scope - Current scope context
286
+ * @param data.oldNode - Previous text node to potentially reuse
287
+ * @param data.hydrationNodes - Nodes available during hydration
288
+ * @returns A text node containing the given value
289
+ *
290
+ * @example
291
+ * ```typescript
292
+ * text: ({ value, oldNode }) => {
293
+ * if (oldNode && oldNode.text !== value) {
294
+ * oldNode.text = value;
295
+ * return oldNode;
296
+ * }
297
+ * return new TextNode(value);
298
+ * }
299
+ * ```
300
+ */
214
301
  text(data: {
215
302
  value: string;
216
303
  scope: TScope | undefined;
217
304
  oldNode: TNode | undefined;
218
305
  hydrationNodes: Array<TNode> | undefined;
219
306
  }): TNode;
307
+ /**
308
+ * Computes scope context for child elements.
309
+ *
310
+ * Called to determine what scope context should be passed to child elements.
311
+ * The scope can be used to pass rendering context like theme, coordinate systems,
312
+ * or namespaces down the component tree.
313
+ *
314
+ * @param data.tag - The element tag
315
+ * @param data.tagName - String representation of the tag
316
+ * @param data.props - The element's props
317
+ * @param data.scope - Current scope context
318
+ * @returns New scope for children, or undefined to inherit current scope
319
+ *
320
+ * @example
321
+ * ```typescript
322
+ * scope: ({ tag, props, scope }) => {
323
+ * if (tag === "svg") {
324
+ * return { ...scope, namespace: "http://www.w3.org/2000/svg" };
325
+ * }
326
+ * return scope;
327
+ * }
328
+ * ```
329
+ */
220
330
  scope(data: {
221
331
  tag: string | symbol;
222
332
  tagName: string;
223
333
  props: Record<string, any>;
224
334
  scope: TScope | undefined;
225
335
  }): TScope | undefined;
336
+ /**
337
+ * Handles raw values (strings or nodes) that bypass normal element processing.
338
+ *
339
+ * Called when rendering Raw elements or other direct node insertions.
340
+ * Should convert string values to appropriate nodes for your environment.
341
+ *
342
+ * @param data.value - Raw string or node value to render
343
+ * @param data.scope - Current scope context
344
+ * @param data.hydrationNodes - Nodes available during hydration
345
+ * @returns ElementValue that can be handled by arrange()
346
+ *
347
+ * @example
348
+ * ```typescript
349
+ * raw: ({ value, scope }) => {
350
+ * if (typeof value === "string") {
351
+ * const container = new Container();
352
+ * container.innerHTML = value;
353
+ * return Array.from(container.children);
354
+ * }
355
+ * return value;
356
+ * }
357
+ * ```
358
+ */
226
359
  raw(data: {
227
360
  value: string | TNode;
228
361
  scope: TScope | undefined;
229
362
  hydrationNodes: Array<TNode> | undefined;
230
363
  }): ElementValue<TNode>;
364
+ /**
365
+ * Updates a node's properties.
366
+ *
367
+ * Called when element props change. Should efficiently update only the
368
+ * properties that have changed. This is where you implement prop-to-attribute
369
+ * mapping, event listener binding, and other property synchronization.
370
+ *
371
+ * @param data.tag - The element tag
372
+ * @param data.tagName - String representation of the tag
373
+ * @param data.node - The node to update
374
+ * @param data.props - New props object
375
+ * @param data.oldProps - Previous props object (undefined for initial render)
376
+ * @param data.scope - Current scope context
377
+ * @param data.copyProps - Props to skip (used for copying between renderers)
378
+ * @param data.isHydrating - Whether currently hydrating
379
+ * @param data.quietProps - Props to not warn about during hydration
380
+ *
381
+ * @example
382
+ * ```typescript
383
+ * patch: ({ node, props, oldProps }) => {
384
+ * for (const [key, value] of Object.entries(props)) {
385
+ * if (oldProps?.[key] !== value) {
386
+ * if (key.startsWith("on")) {
387
+ * node.addEventListener(key.slice(2), value);
388
+ * } else {
389
+ * node[key] = value;
390
+ * }
391
+ * }
392
+ * }
393
+ * }
394
+ * ```
395
+ */
231
396
  patch(data: {
232
397
  tag: string | symbol;
233
398
  tagName: string;
@@ -239,6 +404,32 @@ export interface RenderAdapter<TNode, TScope, TRoot extends TNode | undefined =
239
404
  isHydrating: boolean;
240
405
  quietProps: Set<string> | undefined;
241
406
  }): void;
407
+ /**
408
+ * Arranges child nodes within their parent.
409
+ *
410
+ * Called after child elements are rendered to organize them within their
411
+ * parent node. Should efficiently insert, move, or remove child nodes to
412
+ * match the provided children array.
413
+ *
414
+ * @param data.tag - The parent element tag
415
+ * @param data.tagName - String representation of the tag
416
+ * @param data.node - The parent node
417
+ * @param data.props - The parent element's props
418
+ * @param data.children - Array of child nodes in correct order
419
+ * @param data.oldProps - Previous props (for reference)
420
+ *
421
+ * @example
422
+ * ```typescript
423
+ * arrange: ({ node, children }) => {
424
+ * // Remove existing children
425
+ * node.removeChildren();
426
+ * // Add new children in order
427
+ * for (const child of children) {
428
+ * node.addChild(child);
429
+ * }
430
+ * }
431
+ * ```
432
+ */
242
433
  arrange(data: {
243
434
  tag: string | symbol;
244
435
  tagName: string;
@@ -247,12 +438,73 @@ export interface RenderAdapter<TNode, TScope, TRoot extends TNode | undefined =
247
438
  children: Array<TNode>;
248
439
  oldProps: Record<string, any> | undefined;
249
440
  }): void;
441
+ /**
442
+ * Removes a node from its parent.
443
+ *
444
+ * Called when an element is being unmounted. Should clean up the node
445
+ * and remove it from its parent if appropriate.
446
+ *
447
+ * @param data.node - The node to remove
448
+ * @param data.parentNode - The parent node
449
+ * @param data.isNested - Whether this is a nested removal (child of removed element)
450
+ *
451
+ * @example
452
+ * ```typescript
453
+ * remove: ({ node, parentNode, isNested }) => {
454
+ * // Clean up event listeners, resources, etc.
455
+ * node.cleanup?.();
456
+ * // Remove from parent unless it's a nested removal
457
+ * if (!isNested && parentNode.contains(node)) {
458
+ * parentNode.removeChild(node);
459
+ * }
460
+ * }
461
+ * ```
462
+ */
250
463
  remove(data: {
251
464
  node: TNode;
252
465
  parentNode: TNode;
253
466
  isNested: boolean;
254
467
  }): void;
468
+ /**
469
+ * Reads the final rendered value from an ElementValue.
470
+ *
471
+ * Called to extract the final result from rendered elements. This allows
472
+ * you to transform the internal node representation into the public API
473
+ * that users of your renderer will see.
474
+ *
475
+ * @param value - The ElementValue to read (array, single node, or undefined)
476
+ * @returns The public representation of the rendered value
477
+ *
478
+ * @example
479
+ * ```typescript
480
+ * read: (value) => {
481
+ * if (Array.isArray(value)) {
482
+ * return value.map(node => node.publicAPI);
483
+ * }
484
+ * return value?.publicAPI;
485
+ * }
486
+ * ```
487
+ */
255
488
  read(value: ElementValue<TNode>): TResult;
489
+ /**
490
+ * Performs final rendering to the root container.
491
+ *
492
+ * Called after the entire render cycle is complete. This is where you
493
+ * trigger the actual rendering/presentation in your target environment
494
+ * (e.g., calling render() on a canvas, flushing to the screen, etc.).
495
+ *
496
+ * @param root - The root container
497
+ *
498
+ * @example
499
+ * ```typescript
500
+ * finalize: (root) => {
501
+ * // Trigger actual rendering
502
+ * if (root instanceof PIXIApplication) {
503
+ * root.render();
504
+ * }
505
+ * }
506
+ * ```
507
+ */
256
508
  finalize(root: TRoot): void;
257
509
  }
258
510
  /**
package/crank.js CHANGED
@@ -1,102 +1,6 @@
1
1
  /// <reference types="crank.d.ts" />
2
2
  import { removeEventTargetDelegates, addEventTargetDelegates, clearEventListeners, CustomEventTarget } from './event-target.js';
3
-
4
- function wrap(value) {
5
- return value === undefined ? [] : Array.isArray(value) ? value : [value];
6
- }
7
- function unwrap(arr) {
8
- return arr.length === 0 ? undefined : arr.length === 1 ? arr[0] : arr;
9
- }
10
- /**
11
- * Ensures a value is an array.
12
- *
13
- * This function does the same thing as wrap() above except it handles nulls
14
- * and iterables, so it is appropriate for wrapping user-provided element
15
- * children.
16
- */
17
- function arrayify(value) {
18
- return value == null
19
- ? []
20
- : Array.isArray(value)
21
- ? value
22
- : typeof value === "string" ||
23
- typeof value[Symbol.iterator] !== "function"
24
- ? [value]
25
- : [...value];
26
- }
27
- function isIteratorLike(value) {
28
- return value != null && typeof value.next === "function";
29
- }
30
- function isPromiseLike(value) {
31
- return value != null && typeof value.then === "function";
32
- }
33
- function createRaceRecord(contender) {
34
- const deferreds = new Set();
35
- const record = { deferreds, settled: false };
36
- // This call to `then` happens once for the lifetime of the value.
37
- Promise.resolve(contender).then((value) => {
38
- for (const { resolve } of deferreds) {
39
- resolve(value);
40
- }
41
- deferreds.clear();
42
- record.settled = true;
43
- }, (err) => {
44
- for (const { reject } of deferreds) {
45
- reject(err);
46
- }
47
- deferreds.clear();
48
- record.settled = true;
49
- });
50
- return record;
51
- }
52
- // Promise.race is memory unsafe. This is alternative which is. See:
53
- // https://github.com/nodejs/node/issues/17469#issuecomment-685235106
54
- // Keys are the values passed to race.
55
- // Values are a record of data containing a set of deferreds and whether the
56
- // value has settled.
57
- const wm = new WeakMap();
58
- function safeRace(contenders) {
59
- let deferred;
60
- const result = new Promise((resolve, reject) => {
61
- deferred = { resolve, reject };
62
- for (const contender of contenders) {
63
- if (!isPromiseLike(contender)) {
64
- // If the contender is a not a then-able, attempting to use it as a key
65
- // in the weakmap would throw an error. Luckily, it is safe to call
66
- // `Promise.resolve(contender).then` on regular values multiple
67
- // times because the promise fulfills immediately.
68
- Promise.resolve(contender).then(resolve, reject);
69
- continue;
70
- }
71
- let record = wm.get(contender);
72
- if (record === undefined) {
73
- record = createRaceRecord(contender);
74
- record.deferreds.add(deferred);
75
- wm.set(contender, record);
76
- }
77
- else if (record.settled) {
78
- // If the value has settled, it is safe to call
79
- // `Promise.resolve(contender).then` on it.
80
- Promise.resolve(contender).then(resolve, reject);
81
- }
82
- else {
83
- record.deferreds.add(deferred);
84
- }
85
- }
86
- });
87
- // The finally callback executes when any value settles, preventing any of
88
- // the unresolved values from retaining a reference to the resolved value.
89
- return result.finally(() => {
90
- for (const contender of contenders) {
91
- if (isPromiseLike(contender)) {
92
- const record = wm.get(contender);
93
- if (record) {
94
- record.deferreds.delete(deferred);
95
- }
96
- }
97
- }
98
- });
99
- }
3
+ import { isPromiseLike, unwrap, wrap, arrayify, safeRace, isIteratorLike } from './_utils.js';
100
4
 
101
5
  const NOOP = () => { };
102
6
  function getTagName(tag) {
@@ -261,6 +165,7 @@ const IsInForOfLoop = 1 << 13;
261
165
  const IsInForAwaitOfLoop = 1 << 14;
262
166
  const NeedsToYield = 1 << 15;
263
167
  const PropsAvailable = 1 << 16;
168
+ const IsSchedulingRefresh = 1 << 17;
264
169
  function getFlag(ret, flag) {
265
170
  return !!(ret.f & flag);
266
171
  }
@@ -614,10 +519,10 @@ function diffChildren(adapter, root, host, ctx, scope, parent, newChildren) {
614
519
  }
615
520
  }
616
521
  else if (ret) {
522
+ let candidateFound = false;
617
523
  // we do not need to add the retainer to the graveyard if it is the
618
524
  // fallback of another retainer
619
525
  // search for the tag in fallback chain
620
- let candidateFound = false;
621
526
  for (let predecessor = ret, candidate = ret.fallback; candidate; predecessor = candidate, candidate = candidate.fallback) {
622
527
  if (candidate.el.tag === child.tag) {
623
528
  // If we find a retainer in the fallback chain with the same tag,
@@ -1452,6 +1357,9 @@ class Context extends CustomEventTarget {
1452
1357
  });
1453
1358
  }
1454
1359
  }
1360
+ if (getFlag(ctx.ret, IsScheduling)) {
1361
+ setFlag(ctx.ret, IsSchedulingRefresh);
1362
+ }
1455
1363
  let diff;
1456
1364
  const schedulePromises = [];
1457
1365
  try {
@@ -1797,10 +1705,11 @@ function runComponent(ctx) {
1797
1705
  if (getFlag(ctx.ret, IsInForOfLoop) &&
1798
1706
  !getFlag(ctx.ret, NeedsToYield) &&
1799
1707
  !getFlag(ctx.ret, IsUnmounted) &&
1800
- !getFlag(ctx.ret, IsScheduling)) {
1708
+ !getFlag(ctx.ret, IsSchedulingRefresh)) {
1801
1709
  console.error(`Component <${getTagName(ctx.ret.el.tag)}> yielded/returned more than once in for...of loop`);
1802
1710
  }
1803
1711
  setFlag(ctx.ret, NeedsToYield, false);
1712
+ setFlag(ctx.ret, IsSchedulingRefresh, false);
1804
1713
  if (iteration.done) {
1805
1714
  setFlag(ctx.ret, IsSyncGen, false);
1806
1715
  ctx.iterator = undefined;
@@ -1846,11 +1755,12 @@ function runComponent(ctx) {
1846
1755
  if (getFlag(ctx.ret, IsInForOfLoop) &&
1847
1756
  !getFlag(ctx.ret, NeedsToYield) &&
1848
1757
  !getFlag(ctx.ret, IsUnmounted) &&
1849
- !getFlag(ctx.ret, IsScheduling)) {
1758
+ !getFlag(ctx.ret, IsSchedulingRefresh)) {
1850
1759
  console.error(`Component <${getTagName(ctx.ret.el.tag)}> yielded/returned more than once in for...of loop`);
1851
1760
  }
1852
1761
  }
1853
1762
  setFlag(ctx.ret, NeedsToYield, false);
1763
+ setFlag(ctx.ret, IsSchedulingRefresh, false);
1854
1764
  if (iteration.done) {
1855
1765
  setFlag(ctx.ret, IsAsyncGen, false);
1856
1766
  ctx.iterator = undefined;
@@ -2082,18 +1992,17 @@ function commitComponent(ctx, schedulePromises, hydrationNodes) {
2082
1992
  });
2083
1993
  return getValue(ctx.ret);
2084
1994
  }
2085
- const wasScheduling = getFlag(ctx.ret, IsScheduling);
2086
1995
  const values = commitChildren(ctx.adapter, ctx.host, ctx, ctx.scope, ctx.ret, ctx.index, schedulePromises, hydrationNodes);
2087
1996
  if (getFlag(ctx.ret, IsUnmounted)) {
2088
1997
  return;
2089
1998
  }
2090
1999
  addEventTargetDelegates(ctx.ctx, values);
2091
2000
  // Execute schedule callbacks early to check for async deferral
2092
- const callbacks = scheduleMap.get(ctx);
2001
+ const wasScheduling = getFlag(ctx.ret, IsScheduling);
2093
2002
  let schedulePromises1;
2003
+ const callbacks = scheduleMap.get(ctx);
2094
2004
  if (callbacks) {
2095
2005
  scheduleMap.delete(ctx);
2096
- // TODO: think about error handling for schedule callbacks
2097
2006
  setFlag(ctx.ret, IsScheduling);
2098
2007
  const result = ctx.adapter.read(unwrap(values));
2099
2008
  for (const callback of callbacks) {
@@ -2104,7 +2013,7 @@ function commitComponent(ctx, schedulePromises, hydrationNodes) {
2104
2013
  }
2105
2014
  if (schedulePromises1 && !getFlag(ctx.ret, DidCommit)) {
2106
2015
  const scheduleCallbacksP = Promise.all(schedulePromises1).then(() => {
2107
- setFlag(ctx.ret, IsScheduling, false);
2016
+ setFlag(ctx.ret, IsScheduling, wasScheduling);
2108
2017
  propagateComponent(ctx);
2109
2018
  if (ctx.ret.fallback) {
2110
2019
  unmount(ctx.adapter, ctx.host, ctx.parent, ctx.ret.fallback, false);
@@ -2144,6 +2053,37 @@ function commitComponent(ctx, schedulePromises, hydrationNodes) {
2144
2053
  // if schedule callbacks call refresh() or async mounting is happening.
2145
2054
  return getValue(ctx.ret, true);
2146
2055
  }
2056
+ /**
2057
+ * Checks if a target retainer is active (contributing) in the host's retainer tree.
2058
+ * Performs a downward traversal from host to find if target is in the active path.
2059
+ */
2060
+ function isRetainerActive(target, host) {
2061
+ const stack = [host];
2062
+ while (stack.length > 0) {
2063
+ const current = stack.pop();
2064
+ if (current === target) {
2065
+ return true;
2066
+ }
2067
+ // Add direct children to stack (skip if this is a host boundary)
2068
+ // Host boundaries are: DOM elements (string tags) or Portal, but NOT Fragment
2069
+ const isHostBoundary = current !== host &&
2070
+ ((typeof current.el.tag === "string" && current.el.tag !== Fragment) ||
2071
+ current.el.tag === Portal);
2072
+ if (current.children && !isHostBoundary) {
2073
+ const children = wrap(current.children);
2074
+ for (const child of children) {
2075
+ if (child) {
2076
+ stack.push(child);
2077
+ }
2078
+ }
2079
+ }
2080
+ // Add fallback chains (only if current retainer is using fallback)
2081
+ if (current.fallback && !getFlag(current, DidDiff)) {
2082
+ stack.push(current.fallback);
2083
+ }
2084
+ }
2085
+ return false;
2086
+ }
2147
2087
  /**
2148
2088
  * Propagates component changes up to ancestors when rendering starts from a
2149
2089
  * component via refresh() or multiple for await...of renders. This handles
@@ -2154,14 +2094,20 @@ function propagateComponent(ctx) {
2154
2094
  const values = getChildValues(ctx.ret, ctx.index);
2155
2095
  addEventTargetDelegates(ctx.ctx, values, (ctx1) => ctx1[_ContextState].host === ctx.host);
2156
2096
  const host = ctx.host;
2097
+ const initiator = ctx.ret;
2098
+ // Check if initiator is active in the host's tree
2099
+ if (!isRetainerActive(initiator, host)) {
2100
+ return;
2101
+ }
2157
2102
  const props = stripSpecialProps(host.el.props);
2103
+ const hostChildren = getChildValues(host, 0);
2158
2104
  ctx.adapter.arrange({
2159
2105
  tag: host.el.tag,
2160
2106
  tagName: getTagName(host.el.tag),
2161
2107
  node: host.value,
2162
2108
  props,
2163
2109
  oldProps: props,
2164
- children: getChildValues(host, 0),
2110
+ children: hostChildren,
2165
2111
  });
2166
2112
  flush(ctx.adapter, ctx.root, ctx);
2167
2113
  }