@alloy-js/core 0.23.0-dev.10 → 0.23.0-dev.11

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.
Files changed (131) hide show
  1. package/dist/devtools/index.html +29 -17
  2. package/dist/src/binder.d.ts.map +1 -1
  3. package/dist/src/binder.js +5 -0
  4. package/dist/src/binder.js.map +1 -1
  5. package/dist/src/components/For.d.ts.map +1 -1
  6. package/dist/src/components/For.js +1 -1
  7. package/dist/src/components/For.js.map +1 -1
  8. package/dist/src/components/List.d.ts.map +1 -1
  9. package/dist/src/components/List.js +1 -1
  10. package/dist/src/components/List.js.map +1 -1
  11. package/dist/src/components/Switch.d.ts.map +1 -1
  12. package/dist/src/components/Switch.js +1 -1
  13. package/dist/src/components/Switch.js.map +1 -1
  14. package/dist/src/debug/diagnostics.test.js +3 -2
  15. package/dist/src/debug/diagnostics.test.js.map +1 -1
  16. package/dist/src/debug/effects.d.ts +12 -4
  17. package/dist/src/debug/effects.d.ts.map +1 -1
  18. package/dist/src/debug/effects.js +182 -52
  19. package/dist/src/debug/effects.js.map +1 -1
  20. package/dist/src/debug/effects.test.js +213 -41
  21. package/dist/src/debug/effects.test.js.map +1 -1
  22. package/dist/src/debug/files.d.ts.map +1 -1
  23. package/dist/src/debug/files.js +7 -18
  24. package/dist/src/debug/files.js.map +1 -1
  25. package/dist/src/debug/files.test.js +13 -36
  26. package/dist/src/debug/files.test.js.map +1 -1
  27. package/dist/src/debug/index.d.ts +4 -2
  28. package/dist/src/debug/index.d.ts.map +1 -1
  29. package/dist/src/debug/index.js +4 -2
  30. package/dist/src/debug/index.js.map +1 -1
  31. package/dist/src/debug/message-format.test.d.ts +2 -0
  32. package/dist/src/debug/message-format.test.d.ts.map +1 -0
  33. package/dist/src/debug/message-format.test.js +700 -0
  34. package/dist/src/debug/message-format.test.js.map +1 -0
  35. package/dist/src/debug/render-tree-orphans.test.d.ts +2 -0
  36. package/dist/src/debug/render-tree-orphans.test.d.ts.map +1 -0
  37. package/dist/src/debug/render-tree-orphans.test.js +297 -0
  38. package/dist/src/debug/render-tree-orphans.test.js.map +1 -0
  39. package/dist/src/debug/render.d.ts.map +1 -1
  40. package/dist/src/debug/render.js +83 -130
  41. package/dist/src/debug/render.js.map +1 -1
  42. package/dist/src/debug/render.test.js +91 -128
  43. package/dist/src/debug/render.test.js.map +1 -1
  44. package/dist/src/debug/symbols.d.ts +6 -5
  45. package/dist/src/debug/symbols.d.ts.map +1 -1
  46. package/dist/src/debug/symbols.js +46 -23
  47. package/dist/src/debug/symbols.js.map +1 -1
  48. package/dist/src/debug/symbols.test.js +15 -26
  49. package/dist/src/debug/symbols.test.js.map +1 -1
  50. package/dist/src/debug/trace-writer.d.ts +55 -0
  51. package/dist/src/debug/trace-writer.d.ts.map +1 -0
  52. package/dist/src/debug/trace-writer.js +658 -0
  53. package/dist/src/debug/trace-writer.js.map +1 -0
  54. package/dist/src/debug/trace.d.ts +10 -10
  55. package/dist/src/debug/trace.d.ts.map +1 -1
  56. package/dist/src/debug/trace.js +23 -20
  57. package/dist/src/debug/trace.js.map +1 -1
  58. package/dist/src/devtools/devtools-protocol.d.ts +318 -161
  59. package/dist/src/devtools/devtools-protocol.d.ts.map +1 -1
  60. package/dist/src/devtools/devtools-server.browser.d.ts +0 -5
  61. package/dist/src/devtools/devtools-server.browser.d.ts.map +1 -1
  62. package/dist/src/devtools/devtools-server.browser.js +0 -3
  63. package/dist/src/devtools/devtools-server.browser.js.map +1 -1
  64. package/dist/src/devtools/devtools-server.d.ts +0 -6
  65. package/dist/src/devtools/devtools-server.d.ts.map +1 -1
  66. package/dist/src/devtools/devtools-server.js +212 -24
  67. package/dist/src/devtools/devtools-server.js.map +1 -1
  68. package/dist/src/devtools/devtools-transport.d.ts +2 -2
  69. package/dist/src/devtools/devtools-transport.d.ts.map +1 -1
  70. package/dist/src/devtools/devtools-transport.js +2 -2
  71. package/dist/src/devtools/devtools-transport.js.map +1 -1
  72. package/dist/src/devtools-entry.browser.d.ts +1 -1
  73. package/dist/src/devtools-entry.browser.d.ts.map +1 -1
  74. package/dist/src/devtools-entry.browser.js.map +1 -1
  75. package/dist/src/devtools-entry.d.ts +1 -1
  76. package/dist/src/devtools-entry.d.ts.map +1 -1
  77. package/dist/src/devtools-entry.js.map +1 -1
  78. package/dist/src/diagnostics.d.ts.map +1 -1
  79. package/dist/src/diagnostics.js +5 -5
  80. package/dist/src/diagnostics.js.map +1 -1
  81. package/dist/src/reactivity.d.ts +13 -2
  82. package/dist/src/reactivity.d.ts.map +1 -1
  83. package/dist/src/reactivity.js +96 -13
  84. package/dist/src/reactivity.js.map +1 -1
  85. package/dist/src/render.d.ts.map +1 -1
  86. package/dist/src/render.js +84 -30
  87. package/dist/src/render.js.map +1 -1
  88. package/dist/src/scheduler.d.ts +5 -0
  89. package/dist/src/scheduler.d.ts.map +1 -1
  90. package/dist/src/scheduler.js +94 -23
  91. package/dist/src/scheduler.js.map +1 -1
  92. package/dist/src/utils.d.ts.map +1 -1
  93. package/dist/src/utils.js +11 -5
  94. package/dist/src/utils.js.map +1 -1
  95. package/dist/testing/devtools-utils.d.ts +12 -3
  96. package/dist/testing/devtools-utils.d.ts.map +1 -1
  97. package/dist/testing/devtools-utils.js +26 -4
  98. package/dist/testing/devtools-utils.js.map +1 -1
  99. package/dist/tsconfig.tsbuildinfo +1 -1
  100. package/package.json +1 -1
  101. package/src/binder.ts +47 -38
  102. package/src/components/For.tsx +14 -10
  103. package/src/components/List.tsx +7 -4
  104. package/src/components/Switch.tsx +11 -7
  105. package/src/debug/diagnostics.test.tsx +3 -2
  106. package/src/debug/effects.test.tsx +248 -36
  107. package/src/debug/effects.ts +276 -62
  108. package/src/debug/files.test.tsx +15 -35
  109. package/src/debug/files.ts +11 -11
  110. package/src/debug/index.ts +4 -0
  111. package/src/debug/message-format.test.tsx +759 -0
  112. package/src/debug/render-tree-orphans.test.tsx +344 -0
  113. package/src/debug/render.test.tsx +96 -118
  114. package/src/debug/render.ts +183 -124
  115. package/src/debug/symbols.test.tsx +19 -20
  116. package/src/debug/symbols.ts +106 -23
  117. package/src/debug/trace-writer.ts +969 -0
  118. package/src/debug/trace.ts +25 -28
  119. package/src/devtools/devtools-protocol.ts +361 -176
  120. package/src/devtools/devtools-server.browser.ts +0 -9
  121. package/src/devtools/devtools-server.ts +210 -32
  122. package/src/devtools/devtools-transport.ts +4 -4
  123. package/src/devtools-entry.browser.ts +11 -15
  124. package/src/devtools-entry.ts +9 -15
  125. package/src/diagnostics.ts +14 -5
  126. package/src/reactivity.ts +113 -17
  127. package/src/render.ts +104 -30
  128. package/src/scheduler.ts +145 -26
  129. package/src/utils.tsx +7 -4
  130. package/temp/api.json +142 -20
  131. package/testing/devtools-utils.ts +46 -4
package/src/reactivity.ts CHANGED
@@ -14,14 +14,10 @@ import {
14
14
  toRef as vueToRef,
15
15
  toRefs as vueToRefs,
16
16
  } from "@vue/reactivity";
17
- import {
18
- captureSourceLocation,
19
- debug,
20
- isDevtoolsEnabled,
21
- } from "./debug/index.js";
17
+ import { captureSourceLocation, debug, isDebugEnabled } from "./debug/index.js";
22
18
  import { RenderedTextTree } from "./render.js";
23
19
  import { Children, ComponentCreator } from "./runtime/component.js";
24
- import { scheduler } from "./scheduler.js";
20
+ import { scheduler, setLastTriggerRef } from "./scheduler.js";
25
21
  import type { OutputSymbol } from "./symbols/output-symbol.js";
26
22
 
27
23
  if ((globalThis as any).__ALLOY__) {
@@ -135,7 +131,9 @@ function resolveOwnerEffectContextId(context: Context): number | null {
135
131
  * ContentSlot, mapJoin). Most contexts don't need an isEmpty ref.
136
132
  */
137
133
  export function ensureIsEmpty(context: Context): Ref<boolean> {
138
- context.isEmpty ??= ref(context.childrenWithContent === 0);
134
+ context.isEmpty ??= ref(context.childrenWithContent === 0, {
135
+ isInfrastructure: true,
136
+ });
139
137
  return context.isEmpty;
140
138
  }
141
139
 
@@ -203,7 +201,10 @@ export function findCurrentEffectId(): number | undefined {
203
201
  }
204
202
 
205
203
  export function memo<T>(fn: () => T, equal?: boolean, name?: string): () => T {
206
- const o = shallowRef<T>();
204
+ const memoLabel = name ? `memo:${name}` : "memo";
205
+ const o = shallowRef<T>(undefined as T, {
206
+ label: memoLabel,
207
+ });
207
208
  effect(
208
209
  (prev) => {
209
210
  const res = fn();
@@ -283,6 +284,23 @@ export function effect<T>(
283
284
  refId: id,
284
285
  targetKey,
285
286
  });
287
+ } else if (
288
+ typeof event.target === "object" &&
289
+ event.target !== null &&
290
+ targetKey !== undefined
291
+ ) {
292
+ const id = reactivePropertyRefId(event.target, targetKey);
293
+ debug.effect.ensureReactivePropertyRef({
294
+ id,
295
+ target: event.target,
296
+ key: targetKey,
297
+ });
298
+ debug.effect.track({
299
+ effectId,
300
+ target: event.target,
301
+ refId: id,
302
+ targetKey,
303
+ });
286
304
  } else {
287
305
  debug.effect.track({
288
306
  effectId,
@@ -292,8 +310,21 @@ export function effect<T>(
292
310
  }
293
311
  };
294
312
  effectOpts.onTrigger = (event: any) => {
313
+ if (!("target" in event)) {
314
+ // Vue Dep.notify() chain propagation — no actual reactive target.
315
+ // Skip recording: these are computed→subscriber notifications without
316
+ // a meaningful target reference.
317
+ return;
318
+ }
295
319
  const targetKey =
296
320
  typeof event.key === "symbol" ? event.key.toString() : event.key;
321
+ // findCurrentEffectId() works because onTrigger fires synchronously
322
+ // during the mutation, so globalContext still points to the producer.
323
+ const producerEffectId = findCurrentEffectId();
324
+ const causedBy =
325
+ producerEffectId !== undefined && producerEffectId !== effectId ?
326
+ producerEffectId
327
+ : undefined;
297
328
  if (isRef(event.target)) {
298
329
  const id = refId(event.target);
299
330
  debug.effect.ensureRef({ id, kind: "ref" });
@@ -302,14 +333,34 @@ export function effect<T>(
302
333
  target: event.target,
303
334
  refId: id,
304
335
  targetKey,
305
- kind: "triggered-by",
336
+ causedBy,
306
337
  });
338
+ setLastTriggerRef(effectId, id);
339
+ } else if (
340
+ typeof event.target === "object" &&
341
+ event.target !== null &&
342
+ targetKey !== undefined
343
+ ) {
344
+ const id = reactivePropertyRefId(event.target, targetKey);
345
+ debug.effect.ensureReactivePropertyRef({
346
+ id,
347
+ target: event.target,
348
+ key: targetKey,
349
+ });
350
+ debug.effect.trigger({
351
+ effectId,
352
+ target: event.target,
353
+ refId: id,
354
+ targetKey,
355
+ causedBy,
356
+ });
357
+ setLastTriggerRef(effectId, id);
307
358
  } else {
308
359
  debug.effect.trigger({
309
360
  effectId,
310
361
  target: event.target,
311
362
  targetKey,
312
- kind: "triggered-by",
363
+ causedBy,
313
364
  });
314
365
  }
315
366
  };
@@ -393,7 +444,7 @@ export function ref<T>(
393
444
  ): Ref<T> {
394
445
  const result = vueRef(value) as Ref<T>;
395
446
  debug.effect.registerRef({
396
- id: refId(result, options?.isInfrastructure),
447
+ id: refId(result),
397
448
  kind: "ref",
398
449
  createdAt: captureSourceLocation(),
399
450
  createdByEffectId: globalContext?.meta?.effectId,
@@ -417,18 +468,19 @@ export function shallowReactive<T extends object>(
417
468
  target: T,
418
469
  ): ShallowReactive<T> {
419
470
  const result = vueShallowReactive(target);
420
- if (isDevtoolsEnabled()) {
471
+ if (isDebugEnabled()) {
421
472
  // Store by raw target — Vue's onTrack/onTrigger events pass the raw object, not the proxy.
422
473
  reactiveCreationLocations.set(target, captureSourceLocation());
423
474
  }
424
475
  return result;
425
476
  }
426
477
 
427
- export function shallowRef<T>(value?: T): Ref<T> {
478
+ export function shallowRef<T>(value?: T, options?: { label?: string }): Ref<T> {
428
479
  const result = vueShallowRef(value) as Ref<T>;
429
480
  debug.effect.registerRef({
430
481
  id: refId(result),
431
482
  kind: "shallowRef",
483
+ label: options?.label,
432
484
  createdAt: captureSourceLocation(),
433
485
  createdByEffectId: globalContext?.meta?.effectId,
434
486
  });
@@ -481,18 +533,63 @@ export function toRefs<T extends object>(
481
533
 
482
534
  const seenRefs = new WeakMap<Ref<unknown>, number>();
483
535
  let refIdCounter = 1;
484
- let infraRefIdCounter = -1;
485
536
  const effectIdMap = new WeakMap<object, number>();
486
537
 
487
- export function refId(ref: Ref<unknown>, isInfrastructure?: boolean): number {
538
+ // Stable ID mapping for (reactive, key) pairs — each property acts as a virtual ref.
539
+ const reactivePropertyIds = new WeakMap<object, Map<string | number, number>>();
540
+
541
+ export function refId(ref: Ref<unknown>): number {
488
542
  let id = seenRefs.get(ref);
489
543
  if (id === undefined) {
490
- id = isInfrastructure ? infraRefIdCounter-- : refIdCounter++;
544
+ id = refIdCounter++;
491
545
  seenRefs.set(ref, id);
492
546
  }
493
547
  return id;
494
548
  }
495
549
 
550
+ /**
551
+ * Get a stable ref ID for a property of a reactive object.
552
+ * Each (target, key) pair gets a unique positive ID from the same counter as refs.
553
+ */
554
+ export function reactivePropertyRefId(
555
+ target: object,
556
+ key: string | number,
557
+ ): number {
558
+ let keys = reactivePropertyIds.get(target);
559
+ if (!keys) {
560
+ keys = new Map();
561
+ reactivePropertyIds.set(target, keys);
562
+ }
563
+ let id = keys.get(key);
564
+ if (id === undefined) {
565
+ id = refIdCounter++;
566
+ keys.set(key, id);
567
+ }
568
+ return id;
569
+ }
570
+
571
+ /**
572
+ * Build a human-readable label for a reactive property like `symbolName.prop`.
573
+ */
574
+ export function formatReactivePropertyLabel(
575
+ target: object,
576
+ key: string | number,
577
+ ): string {
578
+ let ownerLabel: string;
579
+ try {
580
+ const str = String(target);
581
+ // OutputSymbol toString() returns something like: TSOutputSymbol "MyInterface"[42]
582
+ // Trim to just the meaningful part
583
+ ownerLabel = str.length > 60 ? str.slice(0, 57) + "..." : str;
584
+ } catch {
585
+ ownerLabel = "reactive";
586
+ }
587
+ if (Array.isArray(target)) {
588
+ ownerLabel = "[]";
589
+ }
590
+ return `${ownerLabel}.${key}`;
591
+ }
592
+
496
593
  /** Allocate a unique reactive target ID from the same counter space as ref IDs. */
497
594
  export function nextReactiveId(): number {
498
595
  return refIdCounter++;
@@ -500,7 +597,6 @@ export function nextReactiveId(): number {
500
597
 
501
598
  export function resetRefIdCounter(): void {
502
599
  refIdCounter = 1;
503
- infraRefIdCounter = -1;
504
600
  }
505
601
 
506
602
  export function getEffectDebugId(effect: object): number | undefined {
package/src/render.ts CHANGED
@@ -6,10 +6,17 @@ import { SourceFileContext } from "./context/source-file.js";
6
6
  import {
7
7
  debug,
8
8
  getRenderNodeId,
9
+ isDevtoolsConnected,
9
10
  isDevtoolsEnabled,
10
11
  type RenderTreeNodeInfo,
11
12
  } from "./debug/index.js";
12
- import { broadcastDevtoolsMessage } from "./devtools/devtools-server.js";
13
+ import {
14
+ beginTransaction,
15
+ closeTrace,
16
+ commitTransaction,
17
+ notifyDiagnosticsReport,
18
+ } from "./debug/trace-writer.js";
19
+ import { isTraceEnabled } from "./debug/trace.js";
13
20
  import {
14
21
  attachDiagnosticsCollector,
15
22
  DiagnosticsCollector,
@@ -52,6 +59,43 @@ import { IntrinsicElement, isIntrinsicElement } from "./runtime/intrinsic.js";
52
59
  import { flushJobs, flushJobsAsync, waitForSignal } from "./scheduler.js";
53
60
 
54
61
  const notifiedErrors = new WeakSet<object>();
62
+
63
+ // ─────────────────────────────────────────────────────────────────────────────
64
+ // Deferred file printing: mark files dirty during render, print once at end
65
+ // ─────────────────────────────────────────────────────────────────────────────
66
+ interface DirtyFileEntry {
67
+ renderNode: RenderedTextTree;
68
+ printOptions: {
69
+ printWidth?: number;
70
+ tabWidth?: number;
71
+ useTabs?: boolean;
72
+ insertFinalNewLine?: boolean;
73
+ };
74
+ path: string;
75
+ filetype: string;
76
+ }
77
+ const dirtyFiles = new Map<string, DirtyFileEntry>();
78
+ const lastFlushTimeByFile = new Map<string, number>();
79
+ const DEVTOOLS_FLUSH_INTERVAL_MS = 1000;
80
+
81
+ function flushDirtyFile(path: string): void {
82
+ const entry = dirtyFiles.get(path);
83
+ if (!entry) return;
84
+ dirtyFiles.delete(path);
85
+ const contents = printTree(entry.renderNode, {
86
+ ...entry.printOptions,
87
+ insertFinalNewLine: entry.printOptions.insertFinalNewLine ?? true,
88
+ noFlush: true,
89
+ });
90
+ debug.files.updated({ path: entry.path, filetype: entry.filetype, contents });
91
+ }
92
+
93
+ function flushDirtyFiles(): void {
94
+ for (const path of [...dirtyFiles.keys()]) {
95
+ flushDirtyFile(path);
96
+ }
97
+ }
98
+
55
99
  let lastRenderError: {
56
100
  error: { name: string; message: string; stack?: string };
57
101
  componentStack: Array<{
@@ -275,10 +319,7 @@ function reportDiagnosticsForTree(tree: RenderedTextTree) {
275
319
  const entries = diagnostics.getDiagnostics();
276
320
  if (entries.length === 0) return;
277
321
  reportDiagnostics(diagnostics);
278
- void broadcastDevtoolsMessage({
279
- type: "diagnostics:report",
280
- diagnostics: entries,
281
- });
322
+ notifyDiagnosticsReport(entries);
282
323
  }
283
324
 
284
325
  // Re-export from print-hook.ts to maintain backwards compatibility
@@ -313,9 +354,13 @@ export function render(
313
354
  const tree = renderTree(children);
314
355
  flushJobs();
315
356
  const output = sourceFilesForTree(tree, options);
357
+ flushDirtyFiles();
316
358
  reportDiagnosticsForTree(tree);
317
359
  reportLastRenderError();
318
360
  debug.render.complete();
361
+ // Only close the trace DB when devtools is NOT running. When devtools is
362
+ // active the DB must remain open for post-render reactive updates.
363
+ if (isTraceEnabled() && !isDevtoolsEnabled()) closeTrace();
319
364
  if (isDevtoolsEnabled()) {
320
365
  void waitForSignal();
321
366
  }
@@ -335,9 +380,13 @@ export async function renderAsync(
335
380
  // Ensure all reactive updates are flushed before printing.
336
381
  await flushJobsAsync();
337
382
  const output = sourceFilesForTree(tree, options);
383
+ flushDirtyFiles();
338
384
  reportDiagnosticsForTree(tree);
339
385
  reportLastRenderError();
340
386
  debug.render.complete();
387
+ // Only close the trace DB when devtools is NOT running. When devtools is
388
+ // active the DB must remain open for post-render reactive updates.
389
+ if (isTraceEnabled() && !isDevtoolsEnabled()) closeTrace();
341
390
 
342
391
  return output;
343
392
  }
@@ -449,17 +498,23 @@ export function renderTree(children: Children) {
449
498
  debug.effect.reset();
450
499
  debug.symbols.reset();
451
500
  debug.files.reset();
501
+ dirtyFiles.clear();
502
+ lastFlushTimeByFile.clear();
452
503
  debug.render.initialize(rootElem);
504
+ if (isTraceEnabled()) beginTransaction();
453
505
  try {
454
506
  root(() => {
455
507
  attachDiagnosticsCollector(diagnostics);
456
508
  renderWorker(rootElem, children);
457
509
  });
458
510
  } catch (e) {
511
+ if (isTraceEnabled()) commitTransaction();
512
+ flushDirtyFiles();
459
513
  notifyRenderError(e);
460
514
  reportLastRenderError();
461
515
  throw e;
462
516
  }
517
+ if (isTraceEnabled()) commitTransaction();
463
518
 
464
519
  diagnosticsByTree.set(rootElem, diagnostics);
465
520
 
@@ -761,8 +816,12 @@ function appendChild(node: RenderedTextTree, rawChild: Child) {
761
816
  }
762
817
  } else if (isComponentCreator(child)) {
763
818
  const index = node.length;
764
- const rerenderToken = isDevtoolsEnabled() ? ref(0) : undefined;
765
- const breakNext = isDevtoolsEnabled() ? ref(false) : undefined;
819
+ const rerenderToken =
820
+ isDevtoolsEnabled() ? ref(0, { isInfrastructure: true }) : undefined;
821
+ const breakNext =
822
+ isDevtoolsEnabled() ?
823
+ ref(false, { isInfrastructure: true })
824
+ : undefined;
766
825
  // todo: remove this effect (only needed for context, not needed for anything else)
767
826
  effect(
768
827
  () => {
@@ -789,15 +848,21 @@ function appendChild(node: RenderedTextTree, rawChild: Child) {
789
848
  source: child.source,
790
849
  isExisting: Array.isArray(existing),
791
850
  actions: {
792
- rerender: () => {
793
- lastRenderError = null;
794
- if (rerenderToken) rerenderToken.value++;
795
- },
796
- rerenderAndBreak: () => {
797
- lastRenderError = null;
798
- if (breakNext) breakNext.value = true;
799
- if (rerenderToken) rerenderToken.value++;
800
- },
851
+ rerender:
852
+ rerenderToken ?
853
+ () => {
854
+ lastRenderError = null;
855
+ rerenderToken.value++;
856
+ }
857
+ : () => {},
858
+ rerenderAndBreak:
859
+ breakNext && rerenderToken ?
860
+ () => {
861
+ lastRenderError = null;
862
+ breakNext.value = true;
863
+ rerenderToken.value++;
864
+ }
865
+ : () => {},
801
866
  },
802
867
  });
803
868
  if (Array.isArray(existing)) {
@@ -809,7 +874,7 @@ function appendChild(node: RenderedTextTree, rawChild: Child) {
809
874
  let childResult: Children | undefined;
810
875
  try {
811
876
  childResult = untrack(() => {
812
- const shouldBreak = breakNext?.value;
877
+ const shouldBreak = breakNext?.value ?? false;
813
878
  if (shouldBreak) {
814
879
  breakNext!.value = false;
815
880
  // eslint-disable-next-line no-debugger
@@ -927,29 +992,38 @@ function findSourceFileContext(node: RenderedTextTree) {
927
992
  }
928
993
 
929
994
  function notifyFileUpdateForNode(node: RenderedTextTree) {
930
- // Only do the expensive printTree when devtools are actually enabled
931
- if (!isDevtoolsEnabled()) return;
995
+ // Only track when devtools or trace are actually enabled
996
+ if (!isDevtoolsEnabled() && !isTraceEnabled()) return;
932
997
  const context = findSourceFileContext(node);
933
998
  if (!context?.meta?.sourceFile) return;
934
999
  if (context.meta.sourceFileReady === false) return;
935
1000
  const sourceFile = context.meta.sourceFile;
936
1001
  const renderNode: RenderedTextTree =
937
1002
  (context.meta.renderNode as RenderedTextTree | undefined) ?? node;
938
- // Pass noFlush here since it flushes jobs and can re-enter rendering
939
- // during effect setup, triggering premature cleanup.
940
- const contents = printTree(renderNode, {
941
- printWidth: context.meta?.printOptions?.printWidth,
942
- tabWidth: context.meta?.printOptions?.tabWidth,
943
- useTabs: context.meta?.printOptions?.useTabs,
944
- insertFinalNewLine: context.meta?.printOptions?.insertFinalNewLine ?? true,
945
- noFlush: true,
946
- });
947
1003
 
948
- debug.files.updated({
1004
+ // Mark this file as dirty — defer the expensive printTree to end of render
1005
+ dirtyFiles.set(sourceFile.path, {
1006
+ renderNode,
1007
+ printOptions: {
1008
+ printWidth: context.meta?.printOptions?.printWidth,
1009
+ tabWidth: context.meta?.printOptions?.tabWidth,
1010
+ useTabs: context.meta?.printOptions?.useTabs,
1011
+ insertFinalNewLine: context.meta?.printOptions?.insertFinalNewLine,
1012
+ },
949
1013
  path: sourceFile.path,
950
1014
  filetype: sourceFile.filetype,
951
- contents,
952
1015
  });
1016
+
1017
+ // When a devtools client is connected, throttle file flushing to ~1s per file
1018
+ // so the user can watch content build up during rendering.
1019
+ if (isDevtoolsConnected()) {
1020
+ const now = Date.now();
1021
+ const lastFlush = lastFlushTimeByFile.get(sourceFile.path) ?? 0;
1022
+ if (now - lastFlush >= DEVTOOLS_FLUSH_INTERVAL_MS) {
1023
+ lastFlushTimeByFile.set(sourceFile.path, now);
1024
+ flushDirtyFile(sourceFile.path);
1025
+ }
1026
+ }
953
1027
  }
954
1028
 
955
1029
  type NormalizedChildren = NormalizedChild | NormalizedChildren[];
package/src/scheduler.ts CHANGED
@@ -1,11 +1,21 @@
1
1
  import { ReactiveEffect } from "@vue/reactivity";
2
2
  import { debug } from "./debug/index.js";
3
+ import {
4
+ beginTransaction,
5
+ commitTransaction,
6
+ insertEffectLifecycle,
7
+ insertSchedulerFlush,
8
+ insertSchedulerJob,
9
+ } from "./debug/trace-writer.js";
10
+ import { isTraceEnabled } from "./debug/trace.js";
11
+ import { getEffectDebugId } from "./reactivity.js";
3
12
 
4
13
  export interface QueueJob {
5
14
  run(): void;
6
15
  }
7
16
  const immediateQueue = new Set<QueueJob>();
8
17
  const queue = new Set<QueueJob>();
18
+
9
19
  function isJobActive(job: QueueJob): boolean {
10
20
  // ReactiveEffect uses bit 0 (flags & 1) as the ACTIVE flag.
11
21
  // Skip effects that were stopped after being queued.
@@ -18,12 +28,19 @@ let resolveWaitForSignal: (() => void) | null = null;
18
28
  let jobSignalPromise: Promise<void> | null = null;
19
29
  let resolveJobSignal: (() => void) | null = null;
20
30
 
21
- // Maps effect debug IDs to the ref that most recently triggered them
31
+ // Maps effect debug IDs to the ref that most recently triggered them.
32
+ // Intentionally overwrites on repeated triggers for the same effect before flush —
33
+ // we only need the last trigger ref for lifecycle recording, not all intermediate ones.
22
34
  const lastTriggerRef = new Map<number, number>();
23
35
 
24
36
  /**
25
37
  * Record which ref triggered an effect re-run.
26
38
  * Called from the onTrigger debug hook before the effect is scheduled.
39
+ *
40
+ * Note: if an effect is triggered multiple times before flush, only the last
41
+ * trigger ref is retained. This is intentional — we care about the most
42
+ * recent cause, and tracking all triggers would add overhead with minimal
43
+ * diagnostic value since only the last mutation actually caused the re-run.
27
44
  */
28
45
  export function setLastTriggerRef(effectDebugId: number, refId: number): void {
29
46
  lastTriggerRef.set(effectDebugId, refId);
@@ -52,6 +69,18 @@ export function queueJob(job: QueueJob | (() => void), immediate = false) {
52
69
  queue.add(job);
53
70
  }
54
71
 
72
+ if (isTraceEnabled()) {
73
+ const effectId = getEffectDebugId(job as object);
74
+ if (effectId !== undefined) {
75
+ insertSchedulerJob(
76
+ "queue",
77
+ effectId,
78
+ immediate,
79
+ immediateQueue.size + queue.size,
80
+ );
81
+ }
82
+ }
83
+
55
84
  if (resolveJobSignal) {
56
85
  resolveJobSignal();
57
86
  resolveJobSignal = null;
@@ -72,19 +101,69 @@ export function trackPromise(promise: Promise<any>) {
72
101
 
73
102
  export function flushJobs() {
74
103
  // First, run all synchronous jobs
104
+ if (isTraceEnabled()) beginTransaction();
75
105
  let job;
106
+ let jobCount = 0;
76
107
  while ((job = takeJob()) !== null) {
77
- if (!isJobActive(job)) continue;
108
+ if (isTraceEnabled()) {
109
+ const effectId = getEffectDebugId(job as object);
110
+ if (effectId !== undefined) {
111
+ insertSchedulerJob(
112
+ "run",
113
+ effectId,
114
+ false,
115
+ immediateQueue.size + queue.size,
116
+ );
117
+ }
118
+ }
119
+ if (!isJobActive(job)) {
120
+ if (isTraceEnabled()) {
121
+ const effectId = getEffectDebugId(job as object);
122
+ if (effectId !== undefined) {
123
+ insertEffectLifecycle(
124
+ effectId,
125
+ "skipped",
126
+ undefined,
127
+ undefined,
128
+ undefined,
129
+ undefined,
130
+ );
131
+ }
132
+ }
133
+ continue;
134
+ }
135
+ if (isTraceEnabled()) {
136
+ const effectId = getEffectDebugId(job as object);
137
+ if (effectId !== undefined) {
138
+ const triggerRefId = lastTriggerRef.get(effectId);
139
+ lastTriggerRef.delete(effectId);
140
+ insertEffectLifecycle(
141
+ effectId,
142
+ "ran",
143
+ triggerRefId,
144
+ undefined,
145
+ undefined,
146
+ undefined,
147
+ );
148
+ }
149
+ }
78
150
  job.run();
151
+ jobCount++;
79
152
  }
80
153
 
81
154
  // If there are no pending promises, we're done
82
155
  if (pendingPromises.size > 0) {
156
+ if (isTraceEnabled()) commitTransaction();
83
157
  throw new Error(
84
158
  "Asynchronous jobs were found but render was called synchronously. Use `renderAsync` instead.",
85
159
  );
86
160
  }
87
161
 
162
+ if (isTraceEnabled()) {
163
+ insertSchedulerFlush(jobCount);
164
+ commitTransaction();
165
+ }
166
+
88
167
  debug.render.flushJobsComplete();
89
168
  }
90
169
 
@@ -115,34 +194,74 @@ export function isWaitingForSignal() {
115
194
 
116
195
  export async function flushJobsAsync() {
117
196
  // Keep running jobs until both the queues are empty and all promises are resolved
118
- while (true) {
119
- // First, run all synchronous jobs
120
- let job;
121
- while ((job = takeJob()) !== null) {
122
- if (!isJobActive(job)) continue;
123
- job.run();
124
- }
197
+ if (isTraceEnabled()) beginTransaction();
198
+ try {
199
+ while (true) {
200
+ // First, run all synchronous jobs
201
+ let job;
202
+ while ((job = takeJob()) !== null) {
203
+ if (!isJobActive(job)) {
204
+ if (isTraceEnabled()) {
205
+ const effectId = getEffectDebugId(job as object);
206
+ if (effectId !== undefined) {
207
+ insertEffectLifecycle(
208
+ effectId,
209
+ "skipped",
210
+ undefined,
211
+ undefined,
212
+ undefined,
213
+ undefined,
214
+ );
215
+ }
216
+ }
217
+ continue;
218
+ }
219
+ if (isTraceEnabled()) {
220
+ const effectId = getEffectDebugId(job as object);
221
+ if (effectId !== undefined) {
222
+ const triggerRefId = lastTriggerRef.get(effectId);
223
+ lastTriggerRef.delete(effectId);
224
+ insertEffectLifecycle(
225
+ effectId,
226
+ "ran",
227
+ triggerRefId,
228
+ undefined,
229
+ undefined,
230
+ undefined,
231
+ );
232
+ }
233
+ }
234
+ job.run();
235
+ }
125
236
 
126
- // If there are no pending promises, we're done
127
- if (pendingPromises.size === 0) {
128
- break;
129
- }
237
+ // If there are no pending promises, we're done
238
+ if (pendingPromises.size === 0) {
239
+ break;
240
+ }
130
241
 
131
- if (!jobSignalPromise) {
132
- jobSignalPromise = new Promise<void>((resolve) => {
133
- resolveJobSignal = resolve;
134
- });
135
- }
242
+ // Commit before awaiting so writes are visible, then re-open after
243
+ if (isTraceEnabled()) commitTransaction();
136
244
 
137
- // Wait for either pending promises to complete or new jobs to arrive
138
- await Promise.race([
139
- Promise.allSettled(Array.from(pendingPromises)),
140
- jobSignalPromise,
141
- ]);
245
+ if (!jobSignalPromise) {
246
+ jobSignalPromise = new Promise<void>((resolve) => {
247
+ resolveJobSignal = resolve;
248
+ });
249
+ }
142
250
 
143
- // Clear the job signal after each iteration so we create a new one next time
144
- jobSignalPromise = null;
145
- resolveJobSignal = null;
251
+ // Wait for either pending promises to complete or new jobs to arrive
252
+ await Promise.race([
253
+ Promise.allSettled(Array.from(pendingPromises)),
254
+ jobSignalPromise,
255
+ ]);
256
+
257
+ // Clear the job signal after each iteration so we create a new one next time
258
+ jobSignalPromise = null;
259
+ resolveJobSignal = null;
260
+
261
+ if (isTraceEnabled()) beginTransaction();
262
+ }
263
+ } finally {
264
+ if (isTraceEnabled()) commitTransaction();
146
265
  }
147
266
 
148
267
  debug.render.flushJobsComplete();