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.
- package/dist/chunk-2IZMPODD.min.js +2 -0
- package/dist/chunk-2IZMPODD.min.js.map +7 -0
- package/dist/chunk-2P7OVL2L.js +1386 -0
- package/dist/chunk-2P7OVL2L.js.map +7 -0
- package/dist/chunk-5EQUBJWQ.js +1365 -0
- package/dist/chunk-5EQUBJWQ.js.map +7 -0
- package/dist/chunk-6DAIK77K.min.js +2 -0
- package/dist/chunk-6DAIK77K.min.js.map +7 -0
- package/dist/chunk-AW3BAPIK.js +1685 -0
- package/dist/chunk-AW3BAPIK.js.map +7 -0
- package/dist/chunk-AZP2EOGX.js +188 -0
- package/dist/chunk-AZP2EOGX.js.map +7 -0
- package/dist/chunk-CCINITLW.js +1692 -0
- package/dist/chunk-CCINITLW.js.map +7 -0
- package/dist/chunk-F2HUXI22.js +1675 -0
- package/dist/chunk-F2HUXI22.js.map +7 -0
- package/dist/chunk-GZRA4IAJ.js +1699 -0
- package/dist/chunk-GZRA4IAJ.js.map +7 -0
- package/dist/chunk-H3GA34JK.js +1384 -0
- package/dist/chunk-H3GA34JK.js.map +7 -0
- package/dist/chunk-KBM6CWG4.min.js +2 -0
- package/dist/chunk-KBM6CWG4.min.js.map +7 -0
- package/dist/chunk-KL7TNUIU.min.js +2 -0
- package/dist/chunk-KL7TNUIU.min.js.map +7 -0
- package/dist/chunk-L6XOF7P4.min.js +2 -0
- package/dist/chunk-L6XOF7P4.min.js.map +7 -0
- package/dist/chunk-M7UEET5O.js +1323 -0
- package/dist/chunk-M7UEET5O.js.map +7 -0
- package/dist/chunk-MH7L756Y.min.js +2 -0
- package/dist/chunk-MH7L756Y.min.js.map +7 -0
- package/dist/chunk-O3SKPRTY.min.js +2 -0
- package/dist/chunk-O3SKPRTY.min.js.map +7 -0
- package/dist/chunk-RI7T5VFD.min.js +2 -0
- package/dist/chunk-RI7T5VFD.min.js.map +7 -0
- package/dist/chunk-RN6QIBWL.min.js +2 -0
- package/dist/chunk-RN6QIBWL.min.js.map +7 -0
- package/dist/chunk-VKCFJ4OT.min.js +2 -0
- package/dist/chunk-VKCFJ4OT.min.js.map +7 -0
- package/dist/chunk-VMTTYB4L.min.js +2 -0
- package/dist/chunk-VMTTYB4L.min.js.map +7 -0
- package/dist/chunk-VP4WLF5A.js +1323 -0
- package/dist/chunk-VP4WLF5A.js.map +7 -0
- package/dist/chunk-YA3W4XKH.js +1323 -0
- package/dist/chunk-YA3W4XKH.js.map +7 -0
- package/dist/index.js +213 -2788
- package/dist/index.js.map +4 -4
- package/dist/index.min.js +6 -6
- package/dist/index.min.js.map +4 -4
- package/dist/jsx-dev-runtime.js +4 -53
- package/dist/jsx-dev-runtime.js.map +3 -3
- package/dist/jsx-dev-runtime.min.js +1 -1
- package/dist/jsx-dev-runtime.min.js.map +4 -4
- package/dist/jsx-runtime.js +4 -53
- package/dist/jsx-runtime.js.map +3 -3
- package/dist/jsx-runtime.min.js +1 -1
- package/dist/jsx-runtime.min.js.map +4 -4
- package/dist/render.js +34 -2044
- package/dist/render.js.map +4 -4
- package/dist/render.min.js +1 -1
- package/dist/render.min.js.map +4 -4
- package/dist/testing.js +13 -1079
- package/dist/testing.js.map +4 -4
- package/dist/testing.min.js +1 -1
- package/dist/testing.min.js.map +4 -4
- package/package.json +2 -2
- package/render.d.ts +18 -0
- package/src/agent-context.js +3 -2
- package/src/dom.js +70 -6
- package/src/guardrails.js +17 -46
- package/src/h.js +15 -3
- package/src/head.js +72 -2
- package/src/hooks.js +65 -4
- package/src/hydration-data.js +34 -0
- package/src/index.js +9 -2
- package/src/reactive.js +100 -1
- package/src/render.js +604 -155
- package/src/server-context.js +48 -0
- 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
|
|
27
|
+
// This catches patterns like `Total: ${count}` or `count > 5` (should be count()).
|
|
29
28
|
//
|
|
30
|
-
// At runtime, we
|
|
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
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
|
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
|
|
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'
|
|
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'
|
|
67
|
-
|
|
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
|
|
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')
|
|
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, '&')
|
|
101
|
+
.replace(/</g, '<')
|
|
102
|
+
.replace(/>/g, '>')
|
|
103
|
+
.replace(/"/g, '"')
|
|
104
|
+
.replace(/'/g, ''');
|
|
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
|
-
|
|
319
|
-
|
|
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
|
|
367
|
-
if (
|
|
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
|
-
|
|
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
|
+
}
|