what-core 0.8.4 → 0.11.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.
Files changed (78) hide show
  1. package/dist/chunk-2IZMPODD.min.js +2 -0
  2. package/dist/chunk-2IZMPODD.min.js.map +7 -0
  3. package/dist/chunk-2P7OVL2L.js +1386 -0
  4. package/dist/chunk-2P7OVL2L.js.map +7 -0
  5. package/dist/chunk-5EQUBJWQ.js +1365 -0
  6. package/dist/chunk-5EQUBJWQ.js.map +7 -0
  7. package/dist/chunk-6DAIK77K.min.js +2 -0
  8. package/dist/chunk-6DAIK77K.min.js.map +7 -0
  9. package/dist/chunk-AW3BAPIK.js +1685 -0
  10. package/dist/chunk-AW3BAPIK.js.map +7 -0
  11. package/dist/chunk-AZP2EOGX.js +188 -0
  12. package/dist/chunk-AZP2EOGX.js.map +7 -0
  13. package/dist/chunk-CCINITLW.js +1692 -0
  14. package/dist/chunk-CCINITLW.js.map +7 -0
  15. package/dist/chunk-F2HUXI22.js +1675 -0
  16. package/dist/chunk-F2HUXI22.js.map +7 -0
  17. package/dist/chunk-GZRA4IAJ.js +1699 -0
  18. package/dist/chunk-GZRA4IAJ.js.map +7 -0
  19. package/dist/chunk-H3GA34JK.js +1384 -0
  20. package/dist/chunk-H3GA34JK.js.map +7 -0
  21. package/dist/chunk-KBM6CWG4.min.js +2 -0
  22. package/dist/chunk-KBM6CWG4.min.js.map +7 -0
  23. package/dist/chunk-KL7TNUIU.min.js +2 -0
  24. package/dist/chunk-KL7TNUIU.min.js.map +7 -0
  25. package/dist/chunk-L6XOF7P4.min.js +2 -0
  26. package/dist/chunk-L6XOF7P4.min.js.map +7 -0
  27. package/dist/chunk-M7UEET5O.js +1323 -0
  28. package/dist/chunk-M7UEET5O.js.map +7 -0
  29. package/dist/chunk-MH7L756Y.min.js +2 -0
  30. package/dist/chunk-MH7L756Y.min.js.map +7 -0
  31. package/dist/chunk-O3SKPRTY.min.js +2 -0
  32. package/dist/chunk-O3SKPRTY.min.js.map +7 -0
  33. package/dist/chunk-RI7T5VFD.min.js +2 -0
  34. package/dist/chunk-RI7T5VFD.min.js.map +7 -0
  35. package/dist/chunk-RN6QIBWL.min.js +2 -0
  36. package/dist/chunk-RN6QIBWL.min.js.map +7 -0
  37. package/dist/chunk-VKCFJ4OT.min.js +2 -0
  38. package/dist/chunk-VKCFJ4OT.min.js.map +7 -0
  39. package/dist/chunk-VMTTYB4L.min.js +2 -0
  40. package/dist/chunk-VMTTYB4L.min.js.map +7 -0
  41. package/dist/chunk-VP4WLF5A.js +1323 -0
  42. package/dist/chunk-VP4WLF5A.js.map +7 -0
  43. package/dist/chunk-YA3W4XKH.js +1323 -0
  44. package/dist/chunk-YA3W4XKH.js.map +7 -0
  45. package/dist/index.js +213 -2788
  46. package/dist/index.js.map +4 -4
  47. package/dist/index.min.js +6 -6
  48. package/dist/index.min.js.map +4 -4
  49. package/dist/jsx-dev-runtime.js +4 -53
  50. package/dist/jsx-dev-runtime.js.map +3 -3
  51. package/dist/jsx-dev-runtime.min.js +1 -1
  52. package/dist/jsx-dev-runtime.min.js.map +4 -4
  53. package/dist/jsx-runtime.js +4 -53
  54. package/dist/jsx-runtime.js.map +3 -3
  55. package/dist/jsx-runtime.min.js +1 -1
  56. package/dist/jsx-runtime.min.js.map +4 -4
  57. package/dist/render.js +34 -2044
  58. package/dist/render.js.map +4 -4
  59. package/dist/render.min.js +1 -1
  60. package/dist/render.min.js.map +4 -4
  61. package/dist/testing.js +13 -1079
  62. package/dist/testing.js.map +4 -4
  63. package/dist/testing.min.js +1 -1
  64. package/dist/testing.min.js.map +4 -4
  65. package/package.json +2 -2
  66. package/render.d.ts +18 -0
  67. package/src/agent-context.js +3 -2
  68. package/src/dom.js +70 -6
  69. package/src/guardrails.js +17 -46
  70. package/src/h.js +15 -3
  71. package/src/head.js +72 -2
  72. package/src/hooks.js +65 -4
  73. package/src/hydration-data.js +34 -0
  74. package/src/index.js +9 -2
  75. package/src/reactive.js +100 -1
  76. package/src/render.js +604 -155
  77. package/src/server-context.js +48 -0
  78. package/src/store.js +6 -2
package/src/guardrails.js CHANGED
@@ -10,7 +10,6 @@ import { createWhatError, collectError } from './errors.js';
10
10
 
11
11
  const guardrails = {
12
12
  signalReadDetection: true,
13
- effectCycleDetection: true,
14
13
  componentNaming: true,
15
14
  importValidation: true,
16
15
  };
@@ -25,16 +24,19 @@ export function getGuardrailConfig() {
25
24
 
26
25
  // --- Guardrail 1: Signal Read Detection ---
27
26
  // Detect when a signal function reference is used where its value was intended.
28
- // This catches the pattern: <span>{count}</span> (should be count())
27
+ // This catches patterns like `Total: ${count}` or `count > 5` (should be count()).
29
28
  //
30
- // At runtime, we can detect this when a signal is coerced to string (via toString/valueOf)
31
- // and warn that it should be called.
29
+ // At runtime, we detect this when a signal is coerced to a string or number
30
+ // (via toString/valueOf) and warn that it should be called.
31
+ //
32
+ // Wiring: what-devtools calls this for every signal it registers (dev only),
33
+ // so any app with devtools installed gets the guardrail automatically.
34
+ // It can also be called manually: installSignalReadGuardrail(sig, 'name').
32
35
 
33
36
  export function installSignalReadGuardrail(signalFn, debugName) {
34
37
  if (!__DEV__ || !guardrails.signalReadDetection) return signalFn;
35
38
 
36
39
  // Override toString to catch template literal coercion
37
- const originalToString = signalFn.toString;
38
40
  signalFn.toString = function () {
39
41
  const err = createWhatError('MISSING_SIGNAL_READ', {
40
42
  signalName: debugName || '(unnamed)',
@@ -58,47 +60,16 @@ export function installSignalReadGuardrail(signalFn, debugName) {
58
60
  return signalFn;
59
61
  }
60
62
 
61
- // --- Guardrail 2: Enhanced Effect Cycle Detection ---
62
- // Track which signals an effect reads AND writes.
63
- // If an effect writes to a signal it reads, warn about the specific cycle.
64
-
65
- const effectWriteTracking = new WeakMap(); // effect -> Set of signal debug names
66
-
67
- export function trackEffectSignalWrite(effectRef, signalDebugName) {
68
- if (!__DEV__ || !guardrails.effectCycleDetection) return;
69
-
70
- if (!effectWriteTracking.has(effectRef)) {
71
- effectWriteTracking.set(effectRef, new Set());
72
- }
73
- effectWriteTracking.get(effectRef).add(signalDebugName);
74
- }
75
-
76
- export function checkEffectCycle(effectRef, readSignals) {
77
- if (!__DEV__ || !guardrails.effectCycleDetection) return null;
78
-
79
- const writes = effectWriteTracking.get(effectRef);
80
- if (!writes || writes.size === 0) return null;
81
-
82
- const overlapping = [];
83
- for (const sigName of readSignals) {
84
- if (writes.has(sigName)) {
85
- overlapping.push(sigName);
86
- }
87
- }
88
-
89
- if (overlapping.length > 0) {
90
- const err = createWhatError('INFINITE_EFFECT', {
91
- effectName: effectRef.fn?.name || '(anonymous)',
92
- signalName: overlapping.join(', '),
93
- });
94
- collectError(err);
95
- return err;
96
- }
97
-
98
- return null;
99
- }
63
+ // NOTE: an "effect cycle detection" guardrail (trackEffectSignalWrite /
64
+ // checkEffectCycle) used to live here, exported but never called by anything
65
+ // it was removed in v0.11 rather than shipped as a dead API. Reviving it
66
+ // requires hooks inside reactive.js: (1) a call on every signal WRITE while
67
+ // an effect is the active listener (to attribute writes to effects), and
68
+ // (2) a post-run callback with the effect's tracked read-set (to intersect
69
+ // reads with writes). reactive.js already detects runaway cascades at flush
70
+ // time (iteration cap), which covers the practical failure mode.
100
71
 
101
- // --- Guardrail 3: Component Naming ---
72
+ // --- Guardrail 2: Component Naming ---
102
73
  // Warn if a component function is not PascalCase.
103
74
 
104
75
  export function checkComponentName(name) {
@@ -119,7 +90,7 @@ function capitalize(str) {
119
90
  return str.charAt(0).toUpperCase() + str.slice(1);
120
91
  }
121
92
 
122
- // --- Guardrail 4: Import Validation ---
93
+ // --- Guardrail 3: Import Validation ---
123
94
  // Verify that all named imports from 'what-framework' are valid exports.
124
95
 
125
96
  const VALID_EXPORTS = new Set([
package/src/h.js CHANGED
@@ -51,7 +51,12 @@ function _flattenSingle(child) {
51
51
  _flattenInto(child, out);
52
52
  return out;
53
53
  }
54
- if (typeof child === 'object' && child._vnode) return [child];
54
+ if (typeof child === 'object') {
55
+ if (child._vnode) return [child];
56
+ // Preserve DOM nodes (HTMLElement, Text, DocumentFragment, etc.) — they
57
+ // can flow through component composition when JSX is pre-realized.
58
+ if (typeof child.nodeType === 'number') return [child];
59
+ }
55
60
  if (typeof child === 'function') return [child];
56
61
  return [String(child)];
57
62
  }
@@ -63,8 +68,15 @@ function _flattenInto(child, out) {
63
68
  for (let i = 0; i < child.length; i++) {
64
69
  _flattenInto(child[i], out);
65
70
  }
66
- } else if (typeof child === 'object' && child._vnode) {
67
- out.push(child);
71
+ } else if (typeof child === 'object') {
72
+ if (child._vnode) {
73
+ out.push(child);
74
+ } else if (typeof child.nodeType === 'number') {
75
+ // DOM node: preserve as-is so insert() can attach it directly.
76
+ out.push(child);
77
+ } else {
78
+ out.push(String(child));
79
+ }
68
80
  } else if (typeof child === 'function') {
69
81
  out.push(child);
70
82
  } else {
package/src/head.js CHANGED
@@ -1,6 +1,14 @@
1
1
  // What Framework - Head Management
2
2
  // Declarative <head> updates from any component.
3
- // Supports title, meta, link, script tags. Auto-deduplicates by key.
3
+ // Supports title, meta, link tags. Auto-deduplicates by key.
4
+ //
5
+ // Isomorphic: on the client it mutates document.head directly (last-one-wins);
6
+ // on the server it writes into the active render context's head sink (see
7
+ // server-context.js), which renderToStringWithHead serializes into <head> HTML.
8
+ // The server and client use IDENTICAL dedup keys so the rendered head matches
9
+ // what the client would produce — no hydration mismatch.
10
+
11
+ import { getServerContext } from './server-context.js';
4
12
 
5
13
  const headState = {
6
14
  title: null,
@@ -12,7 +20,13 @@ const headState = {
12
20
  // Use in any component to set head tags. Last one wins for title/meta.
13
21
 
14
22
  export function Head({ title, meta, link, children }) {
15
- if (typeof document === 'undefined') return null;
23
+ if (typeof document === 'undefined') {
24
+ // Server: collect into the active render context's sink (if a render is
25
+ // collecting head). No active context => no-op (renderToString body-only).
26
+ const ctx = getServerContext();
27
+ if (ctx && ctx.head) writeToSink(ctx.head, { title, meta, link });
28
+ return children ?? null;
29
+ }
16
30
 
17
31
  if (title) {
18
32
  document.title = title;
@@ -36,6 +50,62 @@ export function Head({ title, meta, link, children }) {
36
50
  return children || null;
37
51
  }
38
52
 
53
+ // --- Server-side head collection ---
54
+
55
+ function metaKey(attrs) {
56
+ return attrs.name || attrs.property || attrs.httpEquiv || JSON.stringify(attrs);
57
+ }
58
+
59
+ function writeToSink(sink, { title, meta, link }) {
60
+ if (title != null) sink.title = title;
61
+ if (meta) {
62
+ for (const attrs of (Array.isArray(meta) ? meta : [meta])) {
63
+ sink.metas.set(metaKey(attrs), attrs);
64
+ }
65
+ }
66
+ if (link) {
67
+ for (const attrs of (Array.isArray(link) ? link : [link])) {
68
+ sink.links.set(attrs.rel + (attrs.href || ''), attrs);
69
+ }
70
+ }
71
+ }
72
+
73
+ /** Create a fresh head sink for one render. */
74
+ export function beginHeadCollection() {
75
+ return { title: null, metas: new Map(), links: new Map() };
76
+ }
77
+
78
+ /** Serialize a head sink into escaped <head> HTML (title + meta + link). */
79
+ export function endHeadCollection(sink) {
80
+ if (!sink) return '';
81
+ let out = '';
82
+ if (sink.title != null) out += `<title>${escapeHtml(String(sink.title))}</title>`;
83
+ for (const attrs of sink.metas.values()) out += renderHeadTag('meta', attrs);
84
+ for (const attrs of sink.links.values()) out += renderHeadTag('link', attrs);
85
+ return out;
86
+ }
87
+
88
+ function renderHeadTag(tag, attrs) {
89
+ let s = `<${tag}`;
90
+ for (const [k, v] of Object.entries(attrs)) {
91
+ if (v == null || v === false) continue;
92
+ const name = k === 'httpEquiv' ? 'http-equiv' : k;
93
+ s += ` ${name}="${escapeHtml(String(v))}"`;
94
+ }
95
+ return s + '>';
96
+ }
97
+
98
+ function escapeHtml(str) {
99
+ return str
100
+ .replace(/&/g, '&amp;')
101
+ .replace(/</g, '&lt;')
102
+ .replace(/>/g, '&gt;')
103
+ .replace(/"/g, '&quot;')
104
+ .replace(/'/g, '&#39;');
105
+ }
106
+
107
+ // --- Client DOM helpers ---
108
+
39
109
  function setHeadTag(tag, key, attrs) {
40
110
  const existing = document.head.querySelector(`[data-what-head="${key}"]`);
41
111
  if (existing) {
package/src/hooks.js CHANGED
@@ -5,6 +5,22 @@
5
5
 
6
6
  import { signal, computed, effect, batch, untrack, createRoot, __DEV__ } from './reactive.js';
7
7
  import { getCurrentComponent } from './dom.js';
8
+ import { getServerContext } from './server-context.js';
9
+ import { getLoaderData as _getLoaderData, getResource as _getResource } from './hydration-data.js';
10
+
11
+ // --- useLoaderData ---
12
+ // Returns the current page's server loader data. Works during SSR (reads the
13
+ // active render context) and during/after hydration on the client (reads the
14
+ // consolidated #__what_data payload). Intentionally NOT a component-scoped hook
15
+ // (no hook slot) so it is safe to call anywhere — components, effects, helpers.
16
+ export function useLoaderData() {
17
+ if (typeof document === 'undefined') {
18
+ const ctx = getServerContext();
19
+ return ctx ? ctx.loaderData : undefined;
20
+ }
21
+ // Client: read from the consolidated hydration payload (cached, single parse).
22
+ return _getLoaderData();
23
+ }
8
24
 
9
25
  function getCtx(hookName) {
10
26
  const ctx = getCurrentComponent();
@@ -315,8 +331,53 @@ export function onCleanup(fn) {
315
331
  // Returns [data, { loading, error, refetch, mutate }]
316
332
 
317
333
  export function createResource(fetcher, options = {}) {
318
- const data = signal(options.initialValue ?? null);
319
- const loading = signal(!options.initialValue);
334
+ // --- Server branch: run the fetcher, cache by key on the render context, and
335
+ // suspend (throw the promise) so the nearest Suspense shows its fallback until
336
+ // the data resolves. On re-render the cached value is returned synchronously.
337
+ if (typeof document === 'undefined') {
338
+ const ctx = getServerContext();
339
+ if (ctx) {
340
+ const key = options.key != null ? options.key : `__r${ctx.resourceCounter++}`;
341
+ const cached = ctx.resources.get(key);
342
+ if (cached && cached.status === 'ready') {
343
+ const accessor = () => cached.value;
344
+ return [accessor, { loading: () => false, error: () => null, refetch: () => {}, mutate: () => {} }];
345
+ }
346
+ if (cached && cached.status === 'error') {
347
+ const accessor = () => undefined;
348
+ return [accessor, { loading: () => false, error: () => cached.error, refetch: () => {}, mutate: () => {} }];
349
+ }
350
+ if (!cached) {
351
+ const promise = Promise.resolve()
352
+ .then(() => fetcher(options.source ?? true, {}))
353
+ .then((v) => { ctx.resources.set(key, { status: 'ready', value: v }); })
354
+ .catch((e) => { ctx.resources.set(key, { status: 'error', error: e }); });
355
+ ctx.resources.set(key, { status: 'pending', promise });
356
+ throw promise; // caught by the nearest Suspense boundary
357
+ }
358
+ // pending (shouldn't normally re-reach here within one pass)
359
+ throw cached.promise;
360
+ }
361
+ // No active render context (bare synchronous renderToString): suspend so the
362
+ // nearest Suspense boundary shows its fallback. There's no cache to resolve
363
+ // into here — use renderToStringAsync / renderToStream for resolved data.
364
+ if (options.initialValue != null) {
365
+ const accessor = () => options.initialValue;
366
+ return [accessor, { loading: () => false, error: () => null, refetch: () => {}, mutate: () => {} }];
367
+ }
368
+ throw Promise.resolve().then(() => fetcher(options.source ?? true, {}));
369
+ }
370
+
371
+ // --- Client branch: seed from the hydration payload so SSR'd resources don't
372
+ // refetch on hydrate.
373
+ let seeded = options.initialValue;
374
+ if (seeded == null && options.key != null) {
375
+ const fromPayload = _getResource(options.key);
376
+ if (fromPayload !== undefined) seeded = fromPayload;
377
+ }
378
+
379
+ const data = signal(seeded ?? null);
380
+ const loading = signal(seeded == null);
320
381
  const error = signal(null);
321
382
 
322
383
  let controller = null;
@@ -363,8 +424,8 @@ export function createResource(fetcher, options = {}) {
363
424
  });
364
425
  }
365
426
 
366
- // Initial fetch if no initial value
367
- if (!options.initialValue) {
427
+ // Initial fetch only if we have no value (initial or hydrated from payload)
428
+ if (seeded == null) {
368
429
  refetch(options.source);
369
430
  }
370
431
 
@@ -0,0 +1,34 @@
1
+ // Client hydration payload reader. The server emits a single
2
+ // <script id="__what_data" type="application/json">{loaderData,resources,islandStores}</script>
3
+ // (via serializeState — XSS-safe, valid JSON). This module is the single source
4
+ // of truth the client uses for useLoaderData() and createResource() seeding.
5
+
6
+ let _cache;
7
+
8
+ export function __readHydrationData() {
9
+ if (_cache !== undefined) return _cache;
10
+ if (typeof document === 'undefined') return (_cache = null);
11
+ const el = document.getElementById('__what_data');
12
+ if (!el) return (_cache = null);
13
+ try {
14
+ _cache = JSON.parse(el.textContent);
15
+ } catch {
16
+ _cache = null;
17
+ }
18
+ return _cache;
19
+ }
20
+
21
+ /** Test/HMR hook: drop the cached payload so the next read re-parses. */
22
+ export function __resetHydrationData() {
23
+ _cache = undefined;
24
+ }
25
+
26
+ export function getLoaderData() {
27
+ const data = __readHydrationData();
28
+ return data ? data.loaderData : undefined;
29
+ }
30
+
31
+ export function getResource(key) {
32
+ const data = __readHydrationData();
33
+ return data && data.resources ? data.resources[key] : undefined;
34
+ }
package/src/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // The closest framework to vanilla JS.
3
3
 
4
4
  // Reactive primitives
5
- export { signal, computed, effect, memo as signalMemo, batch, untrack, flushSync, createRoot, getOwner, runWithOwner, onCleanup as onRootCleanup, __setDevToolsHooks } from './reactive.js';
5
+ export { signal, computed, effect, memo as signalMemo, batch, untrack, flushSync, createRoot, getOwner, runWithOwner, onCleanup as onRootCleanup, __setDevToolsHooks, __drainPreinstallBuffer } from './reactive.js';
6
6
 
7
7
  // Fine-grained rendering primitives
8
8
  export { template, _template, _$template, svgTemplate, insert, mapArray, spread, setProp, delegateEvents, on, classList, hydrate, isHydrating, _$createComponent } from './render.js';
@@ -30,6 +30,7 @@ export {
30
30
  onMount,
31
31
  onCleanup,
32
32
  createResource,
33
+ useLoaderData,
33
34
  } from './hooks.js';
34
35
 
35
36
  // Component helpers
@@ -39,7 +40,13 @@ export { memo, lazy, Suspense, ErrorBoundary, Show, For, Switch, Match, Island }
39
40
  export { createStore, derived, storeComputed, atom } from './store.js';
40
41
 
41
42
  // Head management
42
- export { Head, clearHead } from './head.js';
43
+ export { Head, clearHead, beginHeadCollection, endHeadCollection } from './head.js';
44
+
45
+ // SSR render-scoped context (keystone for head collection, loaders, resources)
46
+ export { getServerContext, setServerContext, runWithServerContext } from './server-context.js';
47
+
48
+ // Client hydration payload reader (loader data + resources)
49
+ export { __readHydrationData, __resetHydrationData, getLoaderData, getResource } from './hydration-data.js';
43
50
 
44
51
  // Utilities
45
52
  export {
package/src/reactive.js CHANGED
@@ -287,6 +287,8 @@ function _updateLevel(e) {
287
287
  // Runs a function, auto-tracking signal reads. Re-runs when deps change.
288
288
  // Returns a dispose function.
289
289
 
290
+ const _noopDispose = () => {};
291
+
290
292
  export function effect(fn, opts) {
291
293
  const e = _createEffect(fn);
292
294
  e._level = 1;
@@ -303,6 +305,26 @@ export function effect(fn, opts) {
303
305
  _updateLevel(e);
304
306
  // Mark as stable after first run — subsequent re-runs skip cleanup/re-subscribe
305
307
  if (opts?.stable) e._stable = true;
308
+
309
+ // Zero-dependency release (SPRINT v0.11 C4): an effect that tracked zero
310
+ // signals on its first run can never be notified again — re-tracking only
311
+ // happens during a re-run, and a re-run requires a notification from a
312
+ // subscribed signal. The compiler conservatively wraps destructured props /
313
+ // imported accessors in effects; when those turn out to be plain values the
314
+ // effect is one-shot. If it also registered no cleanup, release it now:
315
+ // no dispose closure, no owner registration, nothing retained.
316
+ // - Effects that returned a cleanup keep full registration so the cleanup
317
+ // still runs on owner disposal.
318
+ // - onCleanup() callbacks register with currentRoot directly (not with the
319
+ // effect), so they are unaffected by this release.
320
+ // - untrack()/peek() reads inside the fn produce zero deps by design — the
321
+ // effect could never re-fire anyway, so releasing is safe.
322
+ if (e.deps.length === 0 && e._cleanup === null) {
323
+ e.disposed = true;
324
+ if (__DEV__ && __devtools) __devtools.onEffectDispose(e);
325
+ return _noopDispose;
326
+ }
327
+
306
328
  const dispose = () => _disposeEffect(e);
307
329
  // Register with current root for automatic cleanup
308
330
  if (currentRoot) {
@@ -571,7 +593,21 @@ function flush() {
571
593
  e._pending = false;
572
594
  if (!e.disposed && !e._onNotify) {
573
595
  const prevDepsLen = e.deps.length;
574
- _runEffect(e);
596
+ // Isolate per-effect errors: one throwing effect must NOT abort the
597
+ // rest of the batch (which would drop queued effects and leave the
598
+ // graph half-updated). NEEDS_UPSTREAM is the iterative-eval sentinel
599
+ // and must still propagate. (AUDIT-2026-06-06 H8)
600
+ try {
601
+ _runEffect(e);
602
+ } catch (err) {
603
+ if (err === NEEDS_UPSTREAM) throw err;
604
+ if (__DEV__ && __devtools?.onError) __devtools.onError(err, { type: 'effect', effect: e });
605
+ // Surface in production too — an uncaught reactive-update error is a
606
+ // real bug; staying silent (as the old throw-out-of-flush did once it
607
+ // escaped) hides it. console.error never aborts the batch.
608
+ try { console.error('[what] Uncaught error in effect during update:', err); } catch { /* no console */ }
609
+ continue;
610
+ }
575
611
  // Update level only if deps changed (graph structure change)
576
612
  if (!e._computed && e.deps.length !== prevDepsLen) {
577
613
  _updateLevel(e);
@@ -843,3 +879,66 @@ export function onCleanup(fn) {
843
879
  currentRoot.disposals.push(fn);
844
880
  }
845
881
  }
882
+
883
+ // devtools: registry-iterator export for P1-9 —
884
+ // Late-install replay buffer. When installDevTools() runs AFTER signals or
885
+ // effects have already been created (the canonical example: a module-scope
886
+ // `export const todos = signal([], 'todos')` in store.js, imported before
887
+ // the devtools entry point), those signals were invisible to what_signals.
888
+ //
889
+ // We install a placeholder __devtools that ONLY buffers creations into weak
890
+ // refs. When the real devtools install via __setDevToolsHooks, they call
891
+ // __drainPreinstallBuffer() to register every buffered primitive.
892
+ //
893
+ // Cost in production: __DEV__ is false → __devtools stays null, no buffering.
894
+ // Cost in dev: one WeakRef per signal/effect created before install.
895
+ if (__DEV__ && typeof WeakRef !== 'undefined') {
896
+ // Cap each buffer so an app that never installs devtools doesn't accumulate
897
+ // refs unbounded. 2k is far more than any realistic component/signal count
898
+ // needed before the devtools entry point runs; once devtools install, the
899
+ // buffer is drained and subsequent creations flow through the real hooks.
900
+ const PREINSTALL_CAP = 2000;
901
+ const buffer = { signals: new Set(), effects: new Set(), components: [] };
902
+ __devtools = {
903
+ __isPreinstallBuffer: true,
904
+ onSignalCreate(sig) {
905
+ if (buffer.signals.size < PREINSTALL_CAP) buffer.signals.add(new WeakRef(sig));
906
+ },
907
+ onSignalUpdate() {},
908
+ onEffectCreate(e) {
909
+ if (buffer.effects.size < PREINSTALL_CAP) buffer.effects.add(new WeakRef(e));
910
+ },
911
+ onEffectDispose() {},
912
+ onEffectRun() {},
913
+ onError() {},
914
+ onComponentMount(ctx) {
915
+ if (buffer.components.length < PREINSTALL_CAP) buffer.components.push(ctx);
916
+ },
917
+ onComponentUnmount() {},
918
+ __buffer: buffer,
919
+ };
920
+ }
921
+
922
+ /**
923
+ * Drain the pre-install buffer. Called by the real devtools hooks after
924
+ * __setDevToolsHooks replaces the placeholder. Returns arrays of live refs.
925
+ */
926
+ export function __drainPreinstallBuffer() {
927
+ if (!__DEV__) return { signals: [], effects: [], components: [] };
928
+ // If the current __devtools is the real one (no __isPreinstallBuffer), the
929
+ // caller installed late and there is nothing to drain from this side.
930
+ const out = { signals: [], effects: [], components: [] };
931
+ const buf = (typeof __preinstallSnapshot !== 'undefined') ? __preinstallSnapshot : null;
932
+ if (!buf) return out;
933
+ for (const ref of buf.signals) { const v = ref.deref?.(); if (v) out.signals.push(v); }
934
+ for (const ref of buf.effects) { const v = ref.deref?.(); if (v) out.effects.push(v); }
935
+ for (const ctx of buf.components) out.components.push(ctx);
936
+ return out;
937
+ }
938
+
939
+ // Capture the placeholder buffer at module load so __drainPreinstallBuffer
940
+ // can return it AFTER __setDevToolsHooks has replaced __devtools.
941
+ let __preinstallSnapshot = null;
942
+ if (__DEV__ && __devtools?.__isPreinstallBuffer) {
943
+ __preinstallSnapshot = __devtools.__buffer;
944
+ }