@alloy-js/core 0.23.0-dev.8 → 0.23.0-dev.9

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 (106) hide show
  1. package/dist/src/components/Prose.js +2 -2
  2. package/dist/src/components/Prose.js.map +1 -1
  3. package/dist/src/components/Scope.d.ts.map +1 -1
  4. package/dist/src/components/Scope.js +2 -0
  5. package/dist/src/components/Scope.js.map +1 -1
  6. package/dist/src/components/SourceDirectory.d.ts.map +1 -1
  7. package/dist/src/components/SourceDirectory.js +1 -2
  8. package/dist/src/components/SourceDirectory.js.map +1 -1
  9. package/dist/src/content-slot.js +2 -2
  10. package/dist/src/content-slot.js.map +1 -1
  11. package/dist/src/context.js +2 -2
  12. package/dist/src/context.js.map +1 -1
  13. package/dist/src/debug/effects.d.ts +4 -0
  14. package/dist/src/debug/effects.d.ts.map +1 -1
  15. package/dist/src/debug/effects.js.map +1 -1
  16. package/dist/src/debug/effects.test.js +22 -24
  17. package/dist/src/debug/effects.test.js.map +1 -1
  18. package/dist/src/debug/index.d.ts +2 -1
  19. package/dist/src/debug/index.d.ts.map +1 -1
  20. package/dist/src/debug/index.js +2 -1
  21. package/dist/src/debug/index.js.map +1 -1
  22. package/dist/src/debug/symbols.d.ts +6 -0
  23. package/dist/src/debug/symbols.d.ts.map +1 -1
  24. package/dist/src/debug/symbols.js +9 -0
  25. package/dist/src/debug/symbols.js.map +1 -1
  26. package/dist/src/index.d.ts +1 -1
  27. package/dist/src/index.d.ts.map +1 -1
  28. package/dist/src/index.js +1 -1
  29. package/dist/src/index.js.map +1 -1
  30. package/dist/src/reactive-union-set.d.ts.map +1 -1
  31. package/dist/src/reactive-union-set.js +13 -3
  32. package/dist/src/reactive-union-set.js.map +1 -1
  33. package/dist/src/reactivity.d.ts +34 -6
  34. package/dist/src/reactivity.d.ts.map +1 -1
  35. package/dist/src/reactivity.js +161 -123
  36. package/dist/src/reactivity.js.map +1 -1
  37. package/dist/src/render-stack.d.ts +1 -0
  38. package/dist/src/render-stack.d.ts.map +1 -1
  39. package/dist/src/render-stack.js +4 -0
  40. package/dist/src/render-stack.js.map +1 -1
  41. package/dist/src/render.d.ts.map +1 -1
  42. package/dist/src/render.js +15 -13
  43. package/dist/src/render.js.map +1 -1
  44. package/dist/src/scheduler.d.ts +5 -0
  45. package/dist/src/scheduler.d.ts.map +1 -1
  46. package/dist/src/scheduler.js +24 -1
  47. package/dist/src/scheduler.js.map +1 -1
  48. package/dist/src/symbols/output-scope.d.ts.map +1 -1
  49. package/dist/src/symbols/output-scope.js +2 -2
  50. package/dist/src/symbols/output-scope.js.map +1 -1
  51. package/dist/src/symbols/output-symbol.d.ts.map +1 -1
  52. package/dist/src/symbols/output-symbol.js +2 -2
  53. package/dist/src/symbols/output-symbol.js.map +1 -1
  54. package/dist/src/symbols/symbol-flow.d.ts.map +1 -1
  55. package/dist/src/symbols/symbol-flow.js +2 -2
  56. package/dist/src/symbols/symbol-flow.js.map +1 -1
  57. package/dist/src/utils.d.ts.map +1 -1
  58. package/dist/src/utils.js +2 -5
  59. package/dist/src/utils.js.map +1 -1
  60. package/dist/test/lazy-isempty.test.d.ts +2 -0
  61. package/dist/test/lazy-isempty.test.d.ts.map +1 -0
  62. package/dist/test/lazy-isempty.test.js +89 -0
  63. package/dist/test/lazy-isempty.test.js.map +1 -0
  64. package/dist/test/reactive-union-set-disposers.test.d.ts +2 -0
  65. package/dist/test/reactive-union-set-disposers.test.d.ts.map +1 -0
  66. package/dist/test/reactive-union-set-disposers.test.js +98 -0
  67. package/dist/test/reactive-union-set-disposers.test.js.map +1 -0
  68. package/dist/test/reactivity/shallow-reactive.test.d.ts +2 -0
  69. package/dist/test/reactivity/shallow-reactive.test.d.ts.map +1 -0
  70. package/dist/test/reactivity/shallow-reactive.test.js +52 -0
  71. package/dist/test/reactivity/shallow-reactive.test.js.map +1 -0
  72. package/dist/test/scheduler-extended.test.d.ts +2 -0
  73. package/dist/test/scheduler-extended.test.d.ts.map +1 -0
  74. package/dist/test/scheduler-extended.test.js +96 -0
  75. package/dist/test/scheduler-extended.test.js.map +1 -0
  76. package/dist/test/scheduler.test.d.ts +2 -0
  77. package/dist/test/scheduler.test.d.ts.map +1 -0
  78. package/dist/test/scheduler.test.js +46 -0
  79. package/dist/test/scheduler.test.js.map +1 -0
  80. package/dist/tsconfig.tsbuildinfo +1 -1
  81. package/package.json +1 -1
  82. package/src/components/Prose.tsx +1 -1
  83. package/src/components/Scope.tsx +2 -0
  84. package/src/components/SourceDirectory.tsx +1 -2
  85. package/src/content-slot.tsx +2 -2
  86. package/src/context.ts +3 -3
  87. package/src/debug/effects.test.tsx +24 -31
  88. package/src/debug/effects.ts +4 -0
  89. package/src/debug/index.ts +2 -0
  90. package/src/debug/symbols.ts +9 -0
  91. package/src/index.ts +0 -1
  92. package/src/reactive-union-set.ts +14 -3
  93. package/src/reactivity.ts +189 -130
  94. package/src/render-stack.ts +5 -0
  95. package/src/render.ts +16 -14
  96. package/src/scheduler.ts +25 -1
  97. package/src/symbols/output-scope.ts +1 -2
  98. package/src/symbols/output-symbol.ts +1 -2
  99. package/src/symbols/symbol-flow.ts +8 -2
  100. package/src/utils.tsx +2 -4
  101. package/temp/api.json +425 -14
  102. package/test/lazy-isempty.test.tsx +106 -0
  103. package/test/reactive-union-set-disposers.test.tsx +112 -0
  104. package/test/reactivity/shallow-reactive.test.tsx +56 -0
  105. package/test/scheduler-extended.test.tsx +122 -0
  106. package/test/scheduler.test.tsx +50 -0
package/src/reactivity.ts CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  computed as vueComputed,
10
10
  effect as vueEffect,
11
11
  ref as vueRef,
12
+ shallowReactive as vueShallowReactive,
12
13
  shallowRef as vueShallowRef,
13
14
  toRef as vueToRef,
14
15
  toRefs as vueToRefs,
@@ -23,40 +24,6 @@ import { Children, ComponentCreator } from "./runtime/component.js";
23
24
  import { scheduler } from "./scheduler.js";
24
25
  import type { OutputSymbol } from "./symbols/output-symbol.js";
25
26
 
26
- function attachEffectWriteDebug(refValue: Ref<unknown>, kind: string) {
27
- if (!isDevtoolsEnabled()) return;
28
- const descriptor =
29
- Object.getOwnPropertyDescriptor(refValue, "value") ??
30
- Object.getOwnPropertyDescriptor(Object.getPrototypeOf(refValue), "value");
31
- if (!descriptor?.get || !descriptor?.set) return;
32
- if ((refValue as any).__alloyDebugWrapped) return;
33
- Object.defineProperty(refValue, "value", {
34
- get: descriptor.get,
35
- set(value: unknown) {
36
- descriptor.set!.call(this, value);
37
- const effectId = globalContext?.meta?.effectId;
38
- if (effectId !== undefined && effectId !== -1) {
39
- const id = refId(refValue);
40
- debug.effect.ensureRef({ id, kind });
41
- debug.effect.trigger({
42
- effectId,
43
- target: refValue,
44
- refId: id,
45
- location: captureSourceLocation(),
46
- kind: "trigger",
47
- });
48
- }
49
- },
50
- enumerable: descriptor.enumerable ?? true,
51
- configurable: true,
52
- });
53
- Object.defineProperty(refValue, "__alloyDebugWrapped", {
54
- value: true,
55
- enumerable: false,
56
- configurable: false,
57
- });
58
- }
59
-
60
27
  if ((globalThis as any).__ALLOY__) {
61
28
  throw new Error(
62
29
  "Multiple versions of Alloy are loaded for this project. This will likely cause undesirable behavior.",
@@ -65,7 +32,8 @@ if ((globalThis as any).__ALLOY__) {
65
32
  (globalThis as any).__ALLOY__ = true;
66
33
 
67
34
  export function getElementCache() {
68
- return getContext()!.elementCache;
35
+ const ctx = getContext()!;
36
+ return (ctx.elementCache ??= new Map());
69
37
  }
70
38
 
71
39
  export type ElementCacheKey =
@@ -79,8 +47,12 @@ export interface Disposable {
79
47
  (): void;
80
48
  }
81
49
 
50
+ let contextIdCounter = 0;
51
+
82
52
  export interface Context {
83
- disposables: Disposable[];
53
+ /** Monotonic numeric ID for trace/debug correlation. */
54
+ id: number;
55
+ disposables?: Disposable[];
84
56
  owner: Context | null;
85
57
 
86
58
  // context providers
@@ -93,7 +65,7 @@ export interface Context {
93
65
  * A cache of RenderTextTree nodes created within this context,
94
66
  * indexed by the component or function which created them.
95
67
  */
96
- elementCache: ElementCache;
68
+ elementCache?: ElementCache;
97
69
  /**
98
70
  * When this context was created by a component, this will
99
71
  * be the component that created it.
@@ -118,9 +90,17 @@ export interface Context {
118
90
 
119
91
  /**
120
92
  * A ref that indicates whether the component is empty.
93
+ * Only allocated when reactively observed (ContentSlot, mapJoin).
121
94
  */
122
95
  isEmpty?: Ref<boolean>;
123
96
 
97
+ /**
98
+ * Cheap boolean tracking the last propagated empty state.
99
+ * Used by notifyContentState() for early-return optimization
100
+ * without requiring a reactive ref on every context.
101
+ */
102
+ _lastEmpty: boolean;
103
+
124
104
  /**
125
105
  * Whether this context is a root context
126
106
  */
@@ -132,21 +112,46 @@ export function getContext() {
132
112
  return globalContext;
133
113
  }
134
114
 
115
+ /**
116
+ * Walk up the owner chain to find the nearest ancestor context that
117
+ * corresponds to an effect (has meta.effectId). This bridges non-effect
118
+ * scopes (like createRoot iterations in For) so the owner chain always
119
+ * connects effect-to-effect.
120
+ */
121
+ function resolveOwnerEffectContextId(context: Context): number | null {
122
+ let owner = context.owner;
123
+ while (owner) {
124
+ if (owner.meta?.effectId !== undefined) {
125
+ return owner.id;
126
+ }
127
+ owner = owner.owner;
128
+ }
129
+ return context.owner?.id ?? null;
130
+ }
131
+
132
+ /**
133
+ * Ensure that a context has an isEmpty ref, creating one if needed.
134
+ * Only call this when you need to reactively observe isEmpty (e.g.,
135
+ * ContentSlot, mapJoin). Most contexts don't need an isEmpty ref.
136
+ */
137
+ export function ensureIsEmpty(context: Context): Ref<boolean> {
138
+ context.isEmpty ??= ref(context.childrenWithContent === 0);
139
+ return context.isEmpty;
140
+ }
141
+
135
142
  export interface RootOptions {
136
143
  componentOwner?: ComponentCreator<any>;
137
144
  }
138
145
 
139
146
  export function root<T>(fn: (d: Disposable) => T, options?: RootOptions): T {
140
147
  const context: Context = {
148
+ id: contextIdCounter++,
141
149
  componentOwner: options?.componentOwner,
142
- disposables: [],
143
150
  owner: globalContext,
144
- context: {},
145
- elementCache: new Map(),
146
151
  takesSymbols: false,
147
152
  takenSymbols: undefined,
148
153
  childrenWithContent: 0,
149
- isEmpty: ref(true),
154
+ _lastEmpty: true,
150
155
  isRoot: true,
151
156
  };
152
157
 
@@ -155,7 +160,7 @@ export function root<T>(fn: (d: Disposable) => T, options?: RootOptions): T {
155
160
  try {
156
161
  ret = untrack(() =>
157
162
  fn(() => {
158
- for (const d of context!.disposables) {
163
+ for (const d of context!.disposables ?? []) {
159
164
  untrack(d);
160
165
  }
161
166
  }),
@@ -183,7 +188,21 @@ export function untrack<T>(fn: () => T): T {
183
188
  return v;
184
189
  }
185
190
 
186
- export function memo<T>(fn: () => T, equal?: boolean): () => T {
191
+ /**
192
+ * Walk up the context owner chain to find the nearest effect ID.
193
+ * Used to attribute reactive mutations to the effect that caused them.
194
+ */
195
+ export function findCurrentEffectId(): number | undefined {
196
+ let ctx = globalContext;
197
+ while (ctx) {
198
+ const id = ctx.meta?.effectId;
199
+ if (id !== undefined && id !== -1) return id;
200
+ ctx = ctx.owner;
201
+ }
202
+ return undefined;
203
+ }
204
+
205
+ export function memo<T>(fn: () => T, equal?: boolean, name?: string): () => T {
187
206
  const o = shallowRef<T>();
188
207
  effect(
189
208
  (prev) => {
@@ -194,10 +213,14 @@ export function memo<T>(fn: () => T, equal?: boolean): () => T {
194
213
  },
195
214
  undefined as T,
196
215
  {
197
- debug: { name: "memo" },
216
+ debug: { name: name ? `memo:${name}` : "memo" },
198
217
  },
199
218
  );
200
- return () => o.value as T;
219
+ const getter = (() => o.value as T) as () => T;
220
+ if (name) {
221
+ Object.defineProperty(getter, "name", { value: name, configurable: true });
222
+ }
223
+ return getter;
201
224
  }
202
225
 
203
226
  export function effect<T>(
@@ -206,13 +229,12 @@ export function effect<T>(
206
229
  options?: EffectOptions,
207
230
  ) {
208
231
  const context: Context = {
209
- context: {},
210
- disposables: [] as (() => void)[],
232
+ id: contextIdCounter++,
211
233
  owner: globalContext,
212
- elementCache: new Map(),
213
234
  takesSymbols: false,
214
235
  takenSymbols: undefined,
215
236
  childrenWithContent: 0,
237
+ _lastEmpty: true,
216
238
  isRoot: false,
217
239
  };
218
240
 
@@ -221,94 +243,93 @@ export function effect<T>(
221
243
  name: debugInfo?.name ?? fn.name,
222
244
  type: debugInfo?.type,
223
245
  createdAt: captureSourceLocation(),
246
+ contextId: context.id,
247
+ ownerContextId: resolveOwnerEffectContextId(context),
224
248
  });
225
249
 
226
- context.meta ??= {};
227
250
  if (effectId !== -1) {
251
+ context.meta ??= {};
228
252
  context.meta.effectId = effectId;
229
253
  }
230
254
 
231
255
  const cleanupFn = (final: boolean) => {
232
256
  const d = context.disposables;
233
- context.disposables = [];
234
- for (let k = 0, len = d.length; k < len; k++) untrack(d[k]);
257
+ context.disposables = undefined;
258
+ if (d) {
259
+ for (let k = 0, len = d.length; k < len; k++) untrack(d[k]);
260
+ }
235
261
 
236
262
  // eslint-disable-next-line @typescript-eslint/no-unused-expressions
237
263
  final && stop(runner);
238
264
  };
239
265
 
240
266
  onCleanup(() => cleanupFn(true));
241
- const runner: ReactiveEffectRunner<void> = vueEffect(
242
- () => {
243
- cleanupFn(false);
244
-
245
- const oldContext = globalContext;
246
- globalContext = context;
247
- try {
248
- current = fn(current);
249
- } finally {
250
- globalContext = oldContext;
267
+ const effectOpts: Record<string, unknown> = {
268
+ // allow recursive effects with 32, 1 and 4 are default flags
269
+ flags: 1 | 4 | 32,
270
+ scheduler: scheduler(),
271
+ };
272
+
273
+ if (effectId !== -1) {
274
+ effectOpts.onTrack = (event: any) => {
275
+ const targetKey =
276
+ typeof event.key === "symbol" ? event.key.toString() : event.key;
277
+ if (isRef(event.target)) {
278
+ const id = refId(event.target);
279
+ debug.effect.ensureRef({ id, kind: "ref" });
280
+ debug.effect.track({
281
+ effectId,
282
+ target: event.target,
283
+ refId: id,
284
+ targetKey,
285
+ });
286
+ } else {
287
+ debug.effect.track({
288
+ effectId,
289
+ target: event.target,
290
+ targetKey,
291
+ });
251
292
  }
252
- },
253
- {
254
- // allow recursive effects with 32, 1 and 4 are default flags
255
- // @ts-expect-error flags is a vue internal thing
256
- flags: 1 | 4 | 32,
257
- scheduler: scheduler(),
258
- onTrack(event) {
259
- if (effectId !== -1) {
260
- const targetKey =
261
- typeof event.key === "symbol" ? event.key.toString() : event.key;
262
- if (isRef(event.target)) {
263
- const id = refId(event.target);
264
- debug.effect.ensureRef({ id, kind: "ref" });
265
- debug.effect.track({
266
- effectId,
267
- target: event.target,
268
- refId: id,
269
- targetKey,
270
- location: captureSourceLocation(),
271
- });
272
- } else {
273
- debug.effect.track({
274
- effectId,
275
- target: event.target,
276
- targetKey,
277
- location: captureSourceLocation(),
278
- });
279
- }
280
- }
281
- // track edge emitted via recordEffectTrack
282
- },
283
- onTrigger(event) {
284
- if (effectId !== -1) {
285
- const targetKey =
286
- typeof event.key === "symbol" ? event.key.toString() : event.key;
287
- if (isRef(event.target)) {
288
- const id = refId(event.target);
289
- debug.effect.ensureRef({ id, kind: "ref" });
290
- debug.effect.trigger({
291
- effectId,
292
- target: event.target,
293
- refId: id,
294
- targetKey,
295
- location: captureSourceLocation(),
296
- kind: "triggered-by",
297
- });
298
- } else {
299
- debug.effect.trigger({
300
- effectId,
301
- target: event.target,
302
- targetKey,
303
- location: captureSourceLocation(),
304
- kind: "triggered-by",
305
- });
306
- }
307
- }
308
- // trigger edge emitted via recordEffectTrigger
309
- },
310
- },
311
- );
293
+ };
294
+ effectOpts.onTrigger = (event: any) => {
295
+ const targetKey =
296
+ typeof event.key === "symbol" ? event.key.toString() : event.key;
297
+ if (isRef(event.target)) {
298
+ const id = refId(event.target);
299
+ debug.effect.ensureRef({ id, kind: "ref" });
300
+ debug.effect.trigger({
301
+ effectId,
302
+ target: event.target,
303
+ refId: id,
304
+ targetKey,
305
+ kind: "triggered-by",
306
+ });
307
+ } else {
308
+ debug.effect.trigger({
309
+ effectId,
310
+ target: event.target,
311
+ targetKey,
312
+ kind: "triggered-by",
313
+ });
314
+ }
315
+ };
316
+ }
317
+
318
+ const runner: ReactiveEffectRunner<void> = vueEffect(() => {
319
+ cleanupFn(false);
320
+
321
+ const oldContext = globalContext;
322
+ globalContext = context;
323
+ try {
324
+ current = fn(current);
325
+ } finally {
326
+ globalContext = oldContext;
327
+ }
328
+ }, effectOpts as any);
329
+
330
+ if (effectId !== -1) {
331
+ effectIdMap.set(runner.effect, effectId);
332
+ }
312
333
  }
313
334
 
314
335
  /**
@@ -331,7 +352,7 @@ export function effect<T>(
331
352
  */
332
353
  export function onCleanup(fn: Disposable) {
333
354
  if (globalContext != null) {
334
- globalContext.disposables.push(fn);
355
+ (globalContext.disposables ??= []).push(fn);
335
356
  }
336
357
  }
337
358
 
@@ -366,21 +387,45 @@ export function isCustomContext(child: Children): child is CustomContext {
366
387
  );
367
388
  }
368
389
 
369
- export function ref<T>(value?: T): Ref<T> {
390
+ export function ref<T>(
391
+ value?: T,
392
+ options?: { isInfrastructure?: boolean },
393
+ ): Ref<T> {
370
394
  const result = vueRef(value) as Ref<T>;
371
- attachEffectWriteDebug(result, "ref");
372
395
  debug.effect.registerRef({
373
- id: refId(result),
396
+ id: refId(result, options?.isInfrastructure),
374
397
  kind: "ref",
375
398
  createdAt: captureSourceLocation(),
376
399
  createdByEffectId: globalContext?.meta?.effectId,
400
+ isInfrastructure: options?.isInfrastructure,
377
401
  });
378
402
  return result;
379
403
  }
380
404
 
405
+ // Stores creation location for shallowReactive objects so registerNonRefTarget
406
+ // can look it up later (since targets are lazily registered on first track/trigger).
407
+ const reactiveCreationLocations = new WeakMap<
408
+ object,
409
+ ReturnType<typeof captureSourceLocation>
410
+ >();
411
+
412
+ export function getReactiveCreationLocation(target: object) {
413
+ return reactiveCreationLocations.get(target);
414
+ }
415
+
416
+ export function shallowReactive<T extends object>(
417
+ target: T,
418
+ ): ShallowReactive<T> {
419
+ const result = vueShallowReactive(target);
420
+ if (isDevtoolsEnabled()) {
421
+ // Store by raw target — Vue's onTrack/onTrigger events pass the raw object, not the proxy.
422
+ reactiveCreationLocations.set(target, captureSourceLocation());
423
+ }
424
+ return result;
425
+ }
426
+
381
427
  export function shallowRef<T>(value?: T): Ref<T> {
382
428
  const result = vueShallowRef(value) as Ref<T>;
383
- attachEffectWriteDebug(result, "shallowRef");
384
429
  debug.effect.registerRef({
385
430
  id: refId(result),
386
431
  kind: "shallowRef",
@@ -410,7 +455,6 @@ export function toRef<T extends object, K extends keyof T>(
410
455
  defaultValue === undefined ?
411
456
  (vueToRef(object, key) as Ref<T[K]>)
412
457
  : (vueToRef(object, key, defaultValue) as Ref<T[K]>);
413
- attachEffectWriteDebug(result, "toRef");
414
458
  debug.effect.registerRef({
415
459
  id: refId(result),
416
460
  kind: "toRef",
@@ -425,7 +469,6 @@ export function toRefs<T extends object>(
425
469
  ): { [K in keyof T]: Ref<T[K]> } {
426
470
  const result = vueToRefs(object) as { [K in keyof T]: Ref<T[K]> };
427
471
  for (const refValue of Object.values(result) as Ref<unknown>[]) {
428
- attachEffectWriteDebug(refValue, "toRef");
429
472
  debug.effect.registerRef({
430
473
  id: refId(refValue),
431
474
  kind: "toRef",
@@ -438,12 +481,28 @@ export function toRefs<T extends object>(
438
481
 
439
482
  const seenRefs = new WeakMap<Ref<unknown>, number>();
440
483
  let refIdCounter = 1;
484
+ let infraRefIdCounter = -1;
485
+ const effectIdMap = new WeakMap<object, number>();
441
486
 
442
- export function refId(ref: Ref<unknown>): number {
487
+ export function refId(ref: Ref<unknown>, isInfrastructure?: boolean): number {
443
488
  let id = seenRefs.get(ref);
444
489
  if (id === undefined) {
445
- id = refIdCounter++;
490
+ id = isInfrastructure ? infraRefIdCounter-- : refIdCounter++;
446
491
  seenRefs.set(ref, id);
447
492
  }
448
493
  return id;
449
494
  }
495
+
496
+ /** Allocate a unique reactive target ID from the same counter space as ref IDs. */
497
+ export function nextReactiveId(): number {
498
+ return refIdCounter++;
499
+ }
500
+
501
+ export function resetRefIdCounter(): void {
502
+ refIdCounter = 1;
503
+ infraRefIdCounter = -1;
504
+ }
505
+
506
+ export function getEffectDebugId(effect: object): number | undefined {
507
+ return effectIdMap.get(effect);
508
+ }
@@ -33,6 +33,11 @@ export function popStack() {
33
33
  renderStack.pop();
34
34
  }
35
35
 
36
+ export function currentComponentName(): string | undefined {
37
+ const entry = renderStack[renderStack.length - 1];
38
+ return entry?.component.name || undefined;
39
+ }
40
+
36
41
  export function clearRenderStack() {
37
42
  renderStack.length = 0;
38
43
  }
package/src/render.ts CHANGED
@@ -498,11 +498,12 @@ export function notifyContentState() {
498
498
  const startContext = getContext()!;
499
499
 
500
500
  if (startContext.childrenWithContent === 0) {
501
- if (startContext.isEmpty!.value === true) {
501
+ if (startContext._lastEmpty) {
502
502
  // it was already empty, no work to do.
503
503
  return;
504
504
  }
505
505
 
506
+ startContext._lastEmpty = true;
506
507
  if (startContext.isEmpty) {
507
508
  startContext.isEmpty.value = true;
508
509
  }
@@ -518,18 +519,20 @@ export function notifyContentState() {
518
519
  // This isn't the last content so we have no work to do
519
520
  break;
520
521
  }
522
+ current._lastEmpty = true;
521
523
  if (current.isEmpty) {
522
524
  current.isEmpty.value = true;
523
525
  }
524
526
  current = current.owner;
525
527
  }
526
528
  } else {
527
- if (startContext.isEmpty!.value === false) {
529
+ if (!startContext._lastEmpty) {
528
530
  // it was already non-empty, no work to do.
529
531
  return;
530
532
  }
531
533
 
532
- if (startContext.isEmpty && startContext.isEmpty.value) {
534
+ startContext._lastEmpty = false;
535
+ if (startContext.isEmpty) {
533
536
  startContext.isEmpty.value = false;
534
537
  }
535
538
 
@@ -542,7 +545,8 @@ export function notifyContentState() {
542
545
  break;
543
546
  }
544
547
 
545
- if (current.isEmpty && current.isEmpty.value) {
548
+ current._lastEmpty = false;
549
+ if (current.isEmpty) {
546
550
  current.isEmpty.value = false;
547
551
  }
548
552
 
@@ -757,16 +761,15 @@ function appendChild(node: RenderedTextTree, rawChild: Child) {
757
761
  }
758
762
  } else if (isComponentCreator(child)) {
759
763
  const index = node.length;
760
- const rerenderToken = ref(0);
761
- const breakNext = ref(false);
764
+ const rerenderToken = isDevtoolsEnabled() ? ref(0) : undefined;
765
+ const breakNext = isDevtoolsEnabled() ? ref(false) : undefined;
762
766
  // todo: remove this effect (only needed for context, not needed for anything else)
763
767
  effect(
764
768
  () => {
765
769
  // eslint-disable-next-line @typescript-eslint/no-unused-expressions
766
- rerenderToken.value;
770
+ rerenderToken?.value;
767
771
  const context = getContext();
768
772
  context!.childrenWithContent = 0;
769
- context!.isEmpty ??= ref(true);
770
773
 
771
774
  if (context) context.componentOwner = child;
772
775
  const existing = node[index];
@@ -788,12 +791,12 @@ function appendChild(node: RenderedTextTree, rawChild: Child) {
788
791
  actions: {
789
792
  rerender: () => {
790
793
  lastRenderError = null;
791
- rerenderToken.value++;
794
+ if (rerenderToken) rerenderToken.value++;
792
795
  },
793
796
  rerenderAndBreak: () => {
794
797
  lastRenderError = null;
795
- breakNext.value = true;
796
- rerenderToken.value++;
798
+ if (breakNext) breakNext.value = true;
799
+ if (rerenderToken) rerenderToken.value++;
797
800
  },
798
801
  },
799
802
  });
@@ -806,9 +809,9 @@ function appendChild(node: RenderedTextTree, rawChild: Child) {
806
809
  let childResult: Children | undefined;
807
810
  try {
808
811
  childResult = untrack(() => {
809
- const shouldBreak = breakNext.value;
812
+ const shouldBreak = breakNext?.value;
810
813
  if (shouldBreak) {
811
- breakNext.value = false;
814
+ breakNext!.value = false;
812
815
  // eslint-disable-next-line no-debugger
813
816
  debugger;
814
817
  }
@@ -880,7 +883,6 @@ function appendChild(node: RenderedTextTree, rawChild: Child) {
880
883
  }
881
884
  const context = getContext();
882
885
  context!.childrenWithContent = 0;
883
- context!.isEmpty ??= ref(true);
884
886
 
885
887
  const existing = node[index];
886
888
  const memoNode: RenderedTextTree =
package/src/scheduler.ts CHANGED
@@ -6,17 +6,39 @@ export interface QueueJob {
6
6
  }
7
7
  const immediateQueue = new Set<QueueJob>();
8
8
  const queue = new Set<QueueJob>();
9
+ function isJobActive(job: QueueJob): boolean {
10
+ // ReactiveEffect uses bit 0 (flags & 1) as the ACTIVE flag.
11
+ // Skip effects that were stopped after being queued.
12
+ const flags = (job as any).flags;
13
+ return flags === undefined || (flags & 1) !== 0;
14
+ }
9
15
  const pendingPromises = new Set<Promise<any>>();
10
16
  let waitForSignalPromise: Promise<void> | null = null;
11
17
  let resolveWaitForSignal: (() => void) | null = null;
12
18
  let jobSignalPromise: Promise<void> | null = null;
13
19
  let resolveJobSignal: (() => void) | null = null;
14
20
 
21
+ // Maps effect debug IDs to the ref that most recently triggered them
22
+ const lastTriggerRef = new Map<number, number>();
23
+
24
+ /**
25
+ * Record which ref triggered an effect re-run.
26
+ * Called from the onTrigger debug hook before the effect is scheduled.
27
+ */
28
+ export function setLastTriggerRef(effectDebugId: number, refId: number): void {
29
+ lastTriggerRef.set(effectDebugId, refId);
30
+ }
31
+
15
32
  export function scheduler(immediate = false) {
33
+ if (!immediate) return defaultScheduler;
16
34
  return function (this: ReactiveEffect) {
17
- queueJob(this, immediate);
35
+ queueJob(this, true);
18
36
  };
19
37
  }
38
+
39
+ const defaultScheduler = function (this: ReactiveEffect) {
40
+ queueJob(this, false);
41
+ };
20
42
  export function queueJob(job: QueueJob | (() => void), immediate = false) {
21
43
  // if we have an immediate job, we don't need to queue the normal job.
22
44
  // the set is serving an important purpose here in deduping the effects we run
@@ -52,6 +74,7 @@ export function flushJobs() {
52
74
  // First, run all synchronous jobs
53
75
  let job;
54
76
  while ((job = takeJob()) !== null) {
77
+ if (!isJobActive(job)) continue;
55
78
  job.run();
56
79
  }
57
80
 
@@ -96,6 +119,7 @@ export async function flushJobsAsync() {
96
119
  // First, run all synchronous jobs
97
120
  let job;
98
121
  while ((job = takeJob()) !== null) {
122
+ if (!isJobActive(job)) continue;
99
123
  job.run();
100
124
  }
101
125
 
@@ -1,7 +1,6 @@
1
1
  import {
2
2
  reactive,
3
3
  ReactiveFlags,
4
- shallowReactive,
5
4
  track,
6
5
  TrackOpTypes,
7
6
  trigger,
@@ -10,7 +9,7 @@ import {
10
9
  import type { Binder } from "../binder.js";
11
10
  import { useBinder } from "../context/binder.js";
12
11
  import { inspect } from "../inspect.js";
13
- import { effect, untrack } from "../reactivity.js";
12
+ import { effect, shallowReactive, untrack } from "../reactivity.js";
14
13
  import { OutputDeclarationSpace, OutputSpace } from "./output-space.js";
15
14
  import { OutputSymbol } from "./output-symbol.js";
16
15
 
@@ -3,7 +3,6 @@ import {
3
3
  reactive,
4
4
  ReactiveFlags,
5
5
  Ref,
6
- shallowReactive,
7
6
  track,
8
7
  TrackOpTypes,
9
8
  trigger,
@@ -15,7 +14,7 @@ import { useBinder } from "../context/binder.js";
15
14
  import { debug, TracePhase } from "../debug/index.js";
16
15
  import { inspect } from "../inspect.js";
17
16
  import { NamePolicyGetter } from "../name-policy.js";
18
- import { untrack } from "../reactivity.js";
17
+ import { shallowReactive, untrack } from "../reactivity.js";
19
18
  import {
20
19
  isMemberRefkey,
21
20
  isNamekey,