@pyreon/runtime-dom 0.19.0 → 0.21.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/lib/analysis/index.js.html +1 -1
- package/lib/analysis/keep-alive-entry.js.html +1 -1
- package/lib/index.js +122 -3
- package/lib/types/index.d.ts +70 -16
- package/package.json +6 -6
- package/src/devtools.ts +35 -0
- package/src/index.ts +9 -1
- package/src/manifest.ts +18 -0
- package/src/props.ts +16 -0
- package/src/template.ts +100 -0
- package/src/tests/manifest-snapshot.test.ts +2 -1
- package/src/tests/rs-collapse-h.browser.test.ts +152 -0
- package/src/tests/rs-collapse-h.test.ts +237 -0
- package/src/tests/rs-collapse.browser.test.ts +128 -0
|
@@ -5386,7 +5386,7 @@ var drawChart = (function (exports) {
|
|
|
5386
5386
|
</script>
|
|
5387
5387
|
<script>
|
|
5388
5388
|
/*<!--*/
|
|
5389
|
-
const data = {"version":2,"tree":{"name":"root","children":[{"name":"index.js","children":[{"name":"src","children":[{"uid":"
|
|
5389
|
+
const data = {"version":2,"tree":{"name":"root","children":[{"name":"index.js","children":[{"name":"src","children":[{"uid":"c8f66e40-1","name":"delegate.ts"},{"uid":"c8f66e40-3","name":"hydration-debug.ts"},{"uid":"c8f66e40-5","name":"devtools.ts"},{"uid":"c8f66e40-7","name":"nodes.ts"},{"uid":"c8f66e40-9","name":"props.ts"},{"uid":"c8f66e40-11","name":"mount.ts"},{"uid":"c8f66e40-13","name":"hydrate.ts"},{"uid":"c8f66e40-15","name":"keep-alive.ts"},{"uid":"c8f66e40-17","name":"template.ts"},{"uid":"c8f66e40-19","name":"transition.ts"},{"uid":"c8f66e40-21","name":"transition-group.ts"},{"uid":"c8f66e40-23","name":"index.ts"}]}]}],"isRoot":true},"nodeParts":{"c8f66e40-1":{"renderedLength":2090,"gzipLength":1029,"brotliLength":0,"metaUid":"c8f66e40-0"},"c8f66e40-3":{"renderedLength":1395,"gzipLength":718,"brotliLength":0,"metaUid":"c8f66e40-2"},"c8f66e40-5":{"renderedLength":8163,"gzipLength":2538,"brotliLength":0,"metaUid":"c8f66e40-4"},"c8f66e40-7":{"renderedLength":17556,"gzipLength":4660,"brotliLength":0,"metaUid":"c8f66e40-6"},"c8f66e40-9":{"renderedLength":9054,"gzipLength":3498,"brotliLength":0,"metaUid":"c8f66e40-8"},"c8f66e40-11":{"renderedLength":12232,"gzipLength":3882,"brotliLength":0,"metaUid":"c8f66e40-10"},"c8f66e40-13":{"renderedLength":8312,"gzipLength":2472,"brotliLength":0,"metaUid":"c8f66e40-12"},"c8f66e40-15":{"renderedLength":1518,"gzipLength":724,"brotliLength":0,"metaUid":"c8f66e40-14"},"c8f66e40-17":{"renderedLength":9693,"gzipLength":3751,"brotliLength":0,"metaUid":"c8f66e40-16"},"c8f66e40-19":{"renderedLength":4929,"gzipLength":1410,"brotliLength":0,"metaUid":"c8f66e40-18"},"c8f66e40-21":{"renderedLength":8002,"gzipLength":2092,"brotliLength":0,"metaUid":"c8f66e40-20"},"c8f66e40-23":{"renderedLength":985,"gzipLength":549,"brotliLength":0,"metaUid":"c8f66e40-22"}},"nodeMetas":{"c8f66e40-0":{"id":"/src/delegate.ts","moduleParts":{"index.js":"c8f66e40-1"},"imported":[{"uid":"c8f66e40-24"}],"importedBy":[{"uid":"c8f66e40-22"},{"uid":"c8f66e40-12"},{"uid":"c8f66e40-8"}]},"c8f66e40-2":{"id":"/src/hydration-debug.ts","moduleParts":{"index.js":"c8f66e40-3"},"imported":[],"importedBy":[{"uid":"c8f66e40-22"},{"uid":"c8f66e40-12"}]},"c8f66e40-4":{"id":"/src/devtools.ts","moduleParts":{"index.js":"c8f66e40-5"},"imported":[{"uid":"c8f66e40-24"}],"importedBy":[{"uid":"c8f66e40-22"},{"uid":"c8f66e40-10"}]},"c8f66e40-6":{"id":"/src/nodes.ts","moduleParts":{"index.js":"c8f66e40-7"},"imported":[{"uid":"c8f66e40-25"},{"uid":"c8f66e40-24"}],"importedBy":[{"uid":"c8f66e40-12"},{"uid":"c8f66e40-10"}]},"c8f66e40-8":{"id":"/src/props.ts","moduleParts":{"index.js":"c8f66e40-9"},"imported":[{"uid":"c8f66e40-25"},{"uid":"c8f66e40-24"},{"uid":"c8f66e40-0"}],"importedBy":[{"uid":"c8f66e40-22"},{"uid":"c8f66e40-12"},{"uid":"c8f66e40-10"},{"uid":"c8f66e40-16"}]},"c8f66e40-10":{"id":"/src/mount.ts","moduleParts":{"index.js":"c8f66e40-11"},"imported":[{"uid":"c8f66e40-25"},{"uid":"c8f66e40-24"},{"uid":"c8f66e40-4"},{"uid":"c8f66e40-6"},{"uid":"c8f66e40-8"}],"importedBy":[{"uid":"c8f66e40-22"},{"uid":"c8f66e40-12"},{"uid":"c8f66e40-14"},{"uid":"c8f66e40-16"},{"uid":"c8f66e40-20"}]},"c8f66e40-12":{"id":"/src/hydrate.ts","moduleParts":{"index.js":"c8f66e40-13"},"imported":[{"uid":"c8f66e40-25"},{"uid":"c8f66e40-24"},{"uid":"c8f66e40-0"},{"uid":"c8f66e40-2"},{"uid":"c8f66e40-10"},{"uid":"c8f66e40-6"},{"uid":"c8f66e40-8"}],"importedBy":[{"uid":"c8f66e40-22"}]},"c8f66e40-14":{"id":"/src/keep-alive.ts","moduleParts":{"index.js":"c8f66e40-15"},"imported":[{"uid":"c8f66e40-25"},{"uid":"c8f66e40-24"},{"uid":"c8f66e40-10"}],"importedBy":[{"uid":"c8f66e40-22"}]},"c8f66e40-16":{"id":"/src/template.ts","moduleParts":{"index.js":"c8f66e40-17"},"imported":[{"uid":"c8f66e40-24"},{"uid":"c8f66e40-10"},{"uid":"c8f66e40-8"}],"importedBy":[{"uid":"c8f66e40-22"}]},"c8f66e40-18":{"id":"/src/transition.ts","moduleParts":{"index.js":"c8f66e40-19"},"imported":[{"uid":"c8f66e40-25"},{"uid":"c8f66e40-24"}],"importedBy":[{"uid":"c8f66e40-22"}]},"c8f66e40-20":{"id":"/src/transition-group.ts","moduleParts":{"index.js":"c8f66e40-21"},"imported":[{"uid":"c8f66e40-25"},{"uid":"c8f66e40-24"},{"uid":"c8f66e40-10"}],"importedBy":[{"uid":"c8f66e40-22"}]},"c8f66e40-22":{"id":"/src/index.ts","moduleParts":{"index.js":"c8f66e40-23"},"imported":[{"uid":"c8f66e40-0"},{"uid":"c8f66e40-12"},{"uid":"c8f66e40-2"},{"uid":"c8f66e40-14"},{"uid":"c8f66e40-10"},{"uid":"c8f66e40-8"},{"uid":"c8f66e40-16"},{"uid":"c8f66e40-18"},{"uid":"c8f66e40-20"},{"uid":"c8f66e40-4"}],"importedBy":[],"isEntry":true},"c8f66e40-24":{"id":"@pyreon/reactivity","moduleParts":{},"imported":[],"importedBy":[{"uid":"c8f66e40-0"},{"uid":"c8f66e40-12"},{"uid":"c8f66e40-14"},{"uid":"c8f66e40-10"},{"uid":"c8f66e40-8"},{"uid":"c8f66e40-16"},{"uid":"c8f66e40-18"},{"uid":"c8f66e40-20"},{"uid":"c8f66e40-4"},{"uid":"c8f66e40-6"}]},"c8f66e40-25":{"id":"@pyreon/core","moduleParts":{},"imported":[],"importedBy":[{"uid":"c8f66e40-12"},{"uid":"c8f66e40-14"},{"uid":"c8f66e40-10"},{"uid":"c8f66e40-8"},{"uid":"c8f66e40-18"},{"uid":"c8f66e40-20"},{"uid":"c8f66e40-6"}]}},"env":{"rollup":"4.23.0"},"options":{"gzip":true,"brotli":false,"sourcemap":false}};
|
|
5390
5390
|
|
|
5391
5391
|
const run = () => {
|
|
5392
5392
|
const width = window.innerWidth;
|
|
@@ -5386,7 +5386,7 @@ var drawChart = (function (exports) {
|
|
|
5386
5386
|
</script>
|
|
5387
5387
|
<script>
|
|
5388
5388
|
/*<!--*/
|
|
5389
|
-
const data = {"version":2,"tree":{"name":"root","children":[{"name":"keep-alive-entry.js","children":[{"name":"src","children":[{"uid":"
|
|
5389
|
+
const data = {"version":2,"tree":{"name":"root","children":[{"name":"keep-alive-entry.js","children":[{"name":"src","children":[{"uid":"30eeaaf0-1","name":"devtools.ts"},{"uid":"30eeaaf0-3","name":"nodes.ts"},{"uid":"30eeaaf0-5","name":"delegate.ts"},{"uid":"30eeaaf0-7","name":"props.ts"},{"uid":"30eeaaf0-9","name":"mount.ts"},{"uid":"30eeaaf0-11","name":"keep-alive.ts"}]}]}],"isRoot":true},"nodeParts":{"30eeaaf0-1":{"renderedLength":759,"gzipLength":340,"brotliLength":0,"metaUid":"30eeaaf0-0"},"30eeaaf0-3":{"renderedLength":17556,"gzipLength":4658,"brotliLength":0,"metaUid":"30eeaaf0-2"},"30eeaaf0-5":{"renderedLength":790,"gzipLength":436,"brotliLength":0,"metaUid":"30eeaaf0-4"},"30eeaaf0-7":{"renderedLength":7818,"gzipLength":2974,"brotliLength":0,"metaUid":"30eeaaf0-6"},"30eeaaf0-9":{"renderedLength":12162,"gzipLength":3873,"brotliLength":0,"metaUid":"30eeaaf0-8"},"30eeaaf0-11":{"renderedLength":1518,"gzipLength":724,"brotliLength":0,"metaUid":"30eeaaf0-10"}},"nodeMetas":{"30eeaaf0-0":{"id":"/src/devtools.ts","moduleParts":{"keep-alive-entry.js":"30eeaaf0-1"},"imported":[{"uid":"30eeaaf0-13"}],"importedBy":[{"uid":"30eeaaf0-8"}]},"30eeaaf0-2":{"id":"/src/nodes.ts","moduleParts":{"keep-alive-entry.js":"30eeaaf0-3"},"imported":[{"uid":"30eeaaf0-12"},{"uid":"30eeaaf0-13"}],"importedBy":[{"uid":"30eeaaf0-8"}]},"30eeaaf0-4":{"id":"/src/delegate.ts","moduleParts":{"keep-alive-entry.js":"30eeaaf0-5"},"imported":[{"uid":"30eeaaf0-13"}],"importedBy":[{"uid":"30eeaaf0-6"}]},"30eeaaf0-6":{"id":"/src/props.ts","moduleParts":{"keep-alive-entry.js":"30eeaaf0-7"},"imported":[{"uid":"30eeaaf0-12"},{"uid":"30eeaaf0-13"},{"uid":"30eeaaf0-4"}],"importedBy":[{"uid":"30eeaaf0-8"}]},"30eeaaf0-8":{"id":"/src/mount.ts","moduleParts":{"keep-alive-entry.js":"30eeaaf0-9"},"imported":[{"uid":"30eeaaf0-12"},{"uid":"30eeaaf0-13"},{"uid":"30eeaaf0-0"},{"uid":"30eeaaf0-2"},{"uid":"30eeaaf0-6"}],"importedBy":[{"uid":"30eeaaf0-10"}]},"30eeaaf0-10":{"id":"/src/keep-alive.ts","moduleParts":{"keep-alive-entry.js":"30eeaaf0-11"},"imported":[{"uid":"30eeaaf0-12"},{"uid":"30eeaaf0-13"},{"uid":"30eeaaf0-8"}],"importedBy":[],"isEntry":true},"30eeaaf0-12":{"id":"@pyreon/core","moduleParts":{},"imported":[],"importedBy":[{"uid":"30eeaaf0-10"},{"uid":"30eeaaf0-8"},{"uid":"30eeaaf0-2"},{"uid":"30eeaaf0-6"}]},"30eeaaf0-13":{"id":"@pyreon/reactivity","moduleParts":{},"imported":[],"importedBy":[{"uid":"30eeaaf0-10"},{"uid":"30eeaaf0-8"},{"uid":"30eeaaf0-0"},{"uid":"30eeaaf0-2"},{"uid":"30eeaaf0-6"},{"uid":"30eeaaf0-4"}]}},"env":{"rollup":"4.23.0"},"options":{"gzip":true,"brotli":false,"sourcemap":false}};
|
|
5390
5390
|
|
|
5391
5391
|
const run = () => {
|
|
5392
5392
|
const width = window.innerWidth;
|
package/lib/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { batch, effect, effectScope, renderEffect, runUntracked, setCurrentScope, signal } from "@pyreon/reactivity";
|
|
1
|
+
import { activateReactiveDevtools, batch, deactivateReactiveDevtools, effect, effectScope, getReactiveFires, getReactiveGraph, renderEffect, runUntracked, setCurrentScope, signal } from "@pyreon/reactivity";
|
|
2
2
|
import { EMPTY_PROPS, ForSymbol, Fragment, PortalSymbol, captureContextStack, createRef, cx, dispatchToErrorBoundary, h, makeReactiveProps, nativeCompat, normalizeStyleValue, onMount, onUnmount, propagateError, reportError, restoreContextStack, runWithHooks, toKebabCase } from "@pyreon/core";
|
|
3
3
|
|
|
4
4
|
//#region src/delegate.ts
|
|
@@ -129,6 +129,23 @@ function warnHydrationMismatch(type, expected, actual, path) {
|
|
|
129
129
|
|
|
130
130
|
//#endregion
|
|
131
131
|
//#region src/devtools.ts
|
|
132
|
+
/**
|
|
133
|
+
* Pyreon DevTools — exposes a `__PYREON_DEVTOOLS__` global hook for browser devtools extensions
|
|
134
|
+
* and in-app debugging utilities.
|
|
135
|
+
*
|
|
136
|
+
* Installed automatically on first `mount()` call in the browser.
|
|
137
|
+
* No-op on the server (typeof window === "undefined").
|
|
138
|
+
*
|
|
139
|
+
* Usage:
|
|
140
|
+
* window.__PYREON_DEVTOOLS__.getComponentTree() // root component entries
|
|
141
|
+
* window.__PYREON_DEVTOOLS__.getAllComponents() // flat list of all live components
|
|
142
|
+
* window.__PYREON_DEVTOOLS__.highlight("comp-id") // outline a component's DOM node
|
|
143
|
+
* window.__PYREON_DEVTOOLS__.onComponentMount(cb) // subscribe to mount events
|
|
144
|
+
* window.__PYREON_DEVTOOLS__.onComponentUnmount(cb)// subscribe to unmount events
|
|
145
|
+
* window.__PYREON_DEVTOOLS__.enableOverlay() // Ctrl+Shift+P: hover to inspect components
|
|
146
|
+
* window.__PYREON_DEVTOOLS__.reactive.activate() // opt-in: track the live signal/effect graph
|
|
147
|
+
* window.__PYREON_DEVTOOLS__.reactive.getGraph() // snapshot of signals/derived/effects + edges
|
|
148
|
+
*/
|
|
132
149
|
const _components = /* @__PURE__ */ new Map();
|
|
133
150
|
const _mountListeners = [];
|
|
134
151
|
const _unmountListeners = [];
|
|
@@ -298,7 +315,13 @@ function installDevTools() {
|
|
|
298
315
|
};
|
|
299
316
|
},
|
|
300
317
|
enableOverlay,
|
|
301
|
-
disableOverlay
|
|
318
|
+
disableOverlay,
|
|
319
|
+
reactive: {
|
|
320
|
+
activate: activateReactiveDevtools,
|
|
321
|
+
deactivate: deactivateReactiveDevtools,
|
|
322
|
+
getGraph: getReactiveGraph,
|
|
323
|
+
getFires: getReactiveFires
|
|
324
|
+
}
|
|
302
325
|
};
|
|
303
326
|
window.__PYREON_DEVTOOLS__ = devtools;
|
|
304
327
|
window.addEventListener("keydown", (e) => {
|
|
@@ -1092,6 +1115,21 @@ function applyEventProp(el, key, value) {
|
|
|
1092
1115
|
return () => el.removeEventListener(eventName, batched);
|
|
1093
1116
|
}
|
|
1094
1117
|
/**
|
|
1118
|
+
* Bind ONE event handler through the CANONICAL event path
|
|
1119
|
+
* (`applyEventProp` — the same delegation, batching, and exact
|
|
1120
|
+
* `onXxx`→event-name normalization every compiler-emitted handler
|
|
1121
|
+
* uses). PR 2 of the partial-collapse build (open-work #1): a
|
|
1122
|
+
* collapsed-with-handler site (`_rsCollapseH`) re-attaches the residual
|
|
1123
|
+
* handlers `detectPartialCollapsibleShape` (compiler PR 1) peeled off.
|
|
1124
|
+
* Contract-consistent BY CONSTRUCTION — it IS `applyEventProp`, not a
|
|
1125
|
+
* re-implementation — so a partially-collapsed `<Button onClick=…>`
|
|
1126
|
+
* behaves byte-identically to the 5-layer mount it replaced (same
|
|
1127
|
+
* delegated-event prop slot, same `batch()` wrapping, same cleanup).
|
|
1128
|
+
*/
|
|
1129
|
+
function _bindEvent(el, key, handler) {
|
|
1130
|
+
return applyEventProp(el, key, handler);
|
|
1131
|
+
}
|
|
1132
|
+
/**
|
|
1095
1133
|
* Sink for a single prop's CALLED value (always a primitive / object /
|
|
1096
1134
|
* `null` — never a function). Called both directly for static values and
|
|
1097
1135
|
* from the reactive `renderEffect` for accessor-bound values.
|
|
@@ -2034,6 +2072,87 @@ function _tpl(html, bind) {
|
|
|
2034
2072
|
};
|
|
2035
2073
|
}
|
|
2036
2074
|
/**
|
|
2075
|
+
* Compiler-emitted collapsed rocketstyle call site.
|
|
2076
|
+
*
|
|
2077
|
+
* The runtime half of the P0 compile-time rocketstyle wrapper-collapse.
|
|
2078
|
+
* For a literal-prop call site like `<Button state="primary" size="md">Save</Button>`,
|
|
2079
|
+
* the build resolves the FULL rocketstyle/styler pipeline once (SSR
|
|
2080
|
+
* render of the real component) and the compiler emits ONE `_rsCollapse`
|
|
2081
|
+
* call instead of the 5-layer wrapper mount (rocketstyle → attrs HOC →
|
|
2082
|
+
* Element → Wrapper → styled). Measured 44× wall-clock, mountChild 9→1
|
|
2083
|
+
* (see examples/experiments/e2-static-rocketstyle/RESULTS.md).
|
|
2084
|
+
*
|
|
2085
|
+
* Dual-emit (RFC decision 1): both the light- and dark-resolved class
|
|
2086
|
+
* strings are baked in; `isDark` is the app's live mode accessor (the
|
|
2087
|
+
* compiler threads it from the configured provider, e.g. `useMode` from
|
|
2088
|
+
* `@pyreon/ui-core`). A whole-theme/mode swap re-runs only this binding —
|
|
2089
|
+
* no remount — preserving Pyreon's reactive mode-switch contract. The
|
|
2090
|
+
* resolved CSS rules are injected once at module-eval via the styler's
|
|
2091
|
+
* idempotent `injectRules()` (emitted alongside this call), so the
|
|
2092
|
+
* collapsed site is self-sufficient: no prior runtime mount of the real
|
|
2093
|
+
* component is needed to populate the sheet.
|
|
2094
|
+
*
|
|
2095
|
+
* `bind` is the standard `_tpl` child/event binder for the (static)
|
|
2096
|
+
* children — identical to what the compiler emits for the non-collapsed
|
|
2097
|
+
* template path, so children reactivity / event delegation is unchanged.
|
|
2098
|
+
*
|
|
2099
|
+
* @param html static element HTML WITHOUT the class attr (class is applied reactively)
|
|
2100
|
+
* @param lightClass resolved styler class string for light mode
|
|
2101
|
+
* @param darkClass resolved styler class string for dark mode
|
|
2102
|
+
* @param isDark app mode accessor — `() => boolean` (true ⇒ dark)
|
|
2103
|
+
* @param bind standard _tpl binder for children/events (or null)
|
|
2104
|
+
*/
|
|
2105
|
+
function _rsCollapse(html, lightClass, darkClass, isDark, bind) {
|
|
2106
|
+
return _tpl(html, (el) => {
|
|
2107
|
+
const disposeClass = _bindDirect(isDark, (v) => {
|
|
2108
|
+
el.className = v ? darkClass : lightClass;
|
|
2109
|
+
});
|
|
2110
|
+
const disposeChildren = bind ? bind(el) : null;
|
|
2111
|
+
if (!disposeChildren) return disposeClass;
|
|
2112
|
+
return () => {
|
|
2113
|
+
disposeClass();
|
|
2114
|
+
disposeChildren();
|
|
2115
|
+
};
|
|
2116
|
+
});
|
|
2117
|
+
}
|
|
2118
|
+
/**
|
|
2119
|
+
* Compiler-emitted PARTIALLY-collapsed rocketstyle call site — PR 2 of
|
|
2120
|
+
* the partial-collapse build (`.claude/plans/open-work-2026-q3.md` → #1).
|
|
2121
|
+
*
|
|
2122
|
+
* Identical to {@link _rsCollapse} (one `_tpl` cloneNode, dual-emit
|
|
2123
|
+
* reactive class, no remount on mode swap) PLUS it re-attaches the
|
|
2124
|
+
* residual event handlers `detectPartialCollapsibleShape` (compiler
|
|
2125
|
+
* PR 1) peeled off the `on*`-handler-only subset (the 7.8% the bail
|
|
2126
|
+
* census measured). Handlers are orthogonal to the SSR-resolved styler
|
|
2127
|
+
* class, so `html` / `lightClass` / `darkClass` are byte-identical to a
|
|
2128
|
+
* full-collapse site's — the ONLY delta vs `_rsCollapse` is the handler
|
|
2129
|
+
* re-attach, routed through the CANONICAL `_bindEvent` → `applyEventProp`
|
|
2130
|
+
* path (delegation + batching + name normalization), so the collapsed
|
|
2131
|
+
* node behaves byte-identically to the 5-layer mount it replaced.
|
|
2132
|
+
*
|
|
2133
|
+
* @param handlers `{ onClick: fn, onPointerEnter: fn, … }` — the peeled
|
|
2134
|
+
* residual handlers; compiler PR 3 emits this object literal from the
|
|
2135
|
+
* sliced source spans `detectPartialCollapsibleShape` returned.
|
|
2136
|
+
*/
|
|
2137
|
+
function _rsCollapseH(html, lightClass, darkClass, isDark, handlers, bind) {
|
|
2138
|
+
return _tpl(html, (el) => {
|
|
2139
|
+
const disposeClass = _bindDirect(isDark, (v) => {
|
|
2140
|
+
el.className = v ? darkClass : lightClass;
|
|
2141
|
+
});
|
|
2142
|
+
const handlerDisposers = [];
|
|
2143
|
+
for (const key in handlers) {
|
|
2144
|
+
const d = _bindEvent(el, key, handlers[key]);
|
|
2145
|
+
if (d) handlerDisposers.push(d);
|
|
2146
|
+
}
|
|
2147
|
+
const disposeChildren = bind ? bind(el) : null;
|
|
2148
|
+
return () => {
|
|
2149
|
+
disposeClass();
|
|
2150
|
+
for (const d of handlerDisposers) d();
|
|
2151
|
+
if (disposeChildren) disposeChildren();
|
|
2152
|
+
};
|
|
2153
|
+
});
|
|
2154
|
+
}
|
|
2155
|
+
/**
|
|
2037
2156
|
* Mount a children slot inside a template.
|
|
2038
2157
|
*
|
|
2039
2158
|
* Compiler emits this instead of `createTextNode()` when it detects a
|
|
@@ -2505,5 +2624,5 @@ function mount(root, container) {
|
|
|
2505
2624
|
const render = mount;
|
|
2506
2625
|
|
|
2507
2626
|
//#endregion
|
|
2508
|
-
export { DELEGATED_EVENTS, KeepAlive, Transition, TransitionGroup, applyProps as _applyProps, applyProps, _bindDirect, _bindText, _mountSlot, _tpl, applyProp, createTemplate, delegatedPropName, disableHydrationWarnings, enableHydrationWarnings, hydrateRoot, mount, mountChild, onHydrationMismatch, render, sanitizeHtml, setSanitizer, setupDelegation };
|
|
2627
|
+
export { DELEGATED_EVENTS, KeepAlive, Transition, TransitionGroup, applyProps as _applyProps, applyProps, _bindDirect, _bindText, _mountSlot, _rsCollapse, _rsCollapseH, _tpl, applyProp, createTemplate, delegatedPropName, disableHydrationWarnings, enableHydrationWarnings, hydrateRoot, mount, mountChild, onHydrationMismatch, render, sanitizeHtml, setSanitizer, setupDelegation };
|
|
2509
2628
|
//# sourceMappingURL=index.js.map
|
package/lib/types/index.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ReactiveFire, ReactiveGraph } from "@pyreon/reactivity";
|
|
1
2
|
import { NativeItem, Props, VNode, VNodeChild } from "@pyreon/core";
|
|
2
3
|
|
|
3
4
|
//#region src/delegate.d.ts
|
|
@@ -31,21 +32,6 @@ declare function delegatedPropName(eventName: string): string;
|
|
|
31
32
|
declare function setupDelegation(container: Element): void;
|
|
32
33
|
//#endregion
|
|
33
34
|
//#region src/devtools.d.ts
|
|
34
|
-
/**
|
|
35
|
-
* Pyreon DevTools — exposes a `__PYREON_DEVTOOLS__` global hook for browser devtools extensions
|
|
36
|
-
* and in-app debugging utilities.
|
|
37
|
-
*
|
|
38
|
-
* Installed automatically on first `mount()` call in the browser.
|
|
39
|
-
* No-op on the server (typeof window === "undefined").
|
|
40
|
-
*
|
|
41
|
-
* Usage:
|
|
42
|
-
* window.__PYREON_DEVTOOLS__.getComponentTree() // root component entries
|
|
43
|
-
* window.__PYREON_DEVTOOLS__.getAllComponents() // flat list of all live components
|
|
44
|
-
* window.__PYREON_DEVTOOLS__.highlight("comp-id") // outline a component's DOM node
|
|
45
|
-
* window.__PYREON_DEVTOOLS__.onComponentMount(cb) // subscribe to mount events
|
|
46
|
-
* window.__PYREON_DEVTOOLS__.onComponentUnmount(cb)// subscribe to unmount events
|
|
47
|
-
* window.__PYREON_DEVTOOLS__.enableOverlay() // Ctrl+Shift+P: hover to inspect components
|
|
48
|
-
*/
|
|
49
35
|
interface DevtoolsComponentEntry {
|
|
50
36
|
id: string;
|
|
51
37
|
name: string;
|
|
@@ -64,6 +50,22 @@ interface PyreonDevtools {
|
|
|
64
50
|
/** Toggle the component inspector overlay (also: Ctrl+Shift+P) */
|
|
65
51
|
enableOverlay(): void;
|
|
66
52
|
disableOverlay(): void;
|
|
53
|
+
/**
|
|
54
|
+
* Reactive-graph bridge — powers the devtools Signals / Graph / Effects
|
|
55
|
+
* surfaces. Opt-in and zero-cost until `activate()` is called: nothing
|
|
56
|
+
* is tracked while a devtools client is not attached.
|
|
57
|
+
*/
|
|
58
|
+
reactive: PyreonReactiveDevtools;
|
|
59
|
+
}
|
|
60
|
+
interface PyreonReactiveDevtools {
|
|
61
|
+
/** Start tracking the live signal/computed/effect graph. Idempotent. */
|
|
62
|
+
activate(): void;
|
|
63
|
+
/** Stop tracking + drop all retained registry/timeline state. */
|
|
64
|
+
deactivate(): void;
|
|
65
|
+
/** Fresh snapshot of the reactive graph (nodes + edges). */
|
|
66
|
+
getGraph(): ReactiveGraph;
|
|
67
|
+
/** Bounded recent-fire timeline (oldest → newest). */
|
|
68
|
+
getFires(): ReactiveFire[];
|
|
67
69
|
}
|
|
68
70
|
//#endregion
|
|
69
71
|
//#region src/hydrate.d.ts
|
|
@@ -318,6 +320,58 @@ declare function _bindDirect(source: {
|
|
|
318
320
|
* })
|
|
319
321
|
*/
|
|
320
322
|
declare function _tpl(html: string, bind: (el: HTMLElement) => (() => void) | null): NativeItem;
|
|
323
|
+
/**
|
|
324
|
+
* Compiler-emitted collapsed rocketstyle call site.
|
|
325
|
+
*
|
|
326
|
+
* The runtime half of the P0 compile-time rocketstyle wrapper-collapse.
|
|
327
|
+
* For a literal-prop call site like `<Button state="primary" size="md">Save</Button>`,
|
|
328
|
+
* the build resolves the FULL rocketstyle/styler pipeline once (SSR
|
|
329
|
+
* render of the real component) and the compiler emits ONE `_rsCollapse`
|
|
330
|
+
* call instead of the 5-layer wrapper mount (rocketstyle → attrs HOC →
|
|
331
|
+
* Element → Wrapper → styled). Measured 44× wall-clock, mountChild 9→1
|
|
332
|
+
* (see examples/experiments/e2-static-rocketstyle/RESULTS.md).
|
|
333
|
+
*
|
|
334
|
+
* Dual-emit (RFC decision 1): both the light- and dark-resolved class
|
|
335
|
+
* strings are baked in; `isDark` is the app's live mode accessor (the
|
|
336
|
+
* compiler threads it from the configured provider, e.g. `useMode` from
|
|
337
|
+
* `@pyreon/ui-core`). A whole-theme/mode swap re-runs only this binding —
|
|
338
|
+
* no remount — preserving Pyreon's reactive mode-switch contract. The
|
|
339
|
+
* resolved CSS rules are injected once at module-eval via the styler's
|
|
340
|
+
* idempotent `injectRules()` (emitted alongside this call), so the
|
|
341
|
+
* collapsed site is self-sufficient: no prior runtime mount of the real
|
|
342
|
+
* component is needed to populate the sheet.
|
|
343
|
+
*
|
|
344
|
+
* `bind` is the standard `_tpl` child/event binder for the (static)
|
|
345
|
+
* children — identical to what the compiler emits for the non-collapsed
|
|
346
|
+
* template path, so children reactivity / event delegation is unchanged.
|
|
347
|
+
*
|
|
348
|
+
* @param html static element HTML WITHOUT the class attr (class is applied reactively)
|
|
349
|
+
* @param lightClass resolved styler class string for light mode
|
|
350
|
+
* @param darkClass resolved styler class string for dark mode
|
|
351
|
+
* @param isDark app mode accessor — `() => boolean` (true ⇒ dark)
|
|
352
|
+
* @param bind standard _tpl binder for children/events (or null)
|
|
353
|
+
*/
|
|
354
|
+
declare function _rsCollapse(html: string, lightClass: string, darkClass: string, isDark: () => boolean, bind?: ((el: HTMLElement) => (() => void) | null) | null): NativeItem;
|
|
355
|
+
/**
|
|
356
|
+
* Compiler-emitted PARTIALLY-collapsed rocketstyle call site — PR 2 of
|
|
357
|
+
* the partial-collapse build (`.claude/plans/open-work-2026-q3.md` → #1).
|
|
358
|
+
*
|
|
359
|
+
* Identical to {@link _rsCollapse} (one `_tpl` cloneNode, dual-emit
|
|
360
|
+
* reactive class, no remount on mode swap) PLUS it re-attaches the
|
|
361
|
+
* residual event handlers `detectPartialCollapsibleShape` (compiler
|
|
362
|
+
* PR 1) peeled off the `on*`-handler-only subset (the 7.8% the bail
|
|
363
|
+
* census measured). Handlers are orthogonal to the SSR-resolved styler
|
|
364
|
+
* class, so `html` / `lightClass` / `darkClass` are byte-identical to a
|
|
365
|
+
* full-collapse site's — the ONLY delta vs `_rsCollapse` is the handler
|
|
366
|
+
* re-attach, routed through the CANONICAL `_bindEvent` → `applyEventProp`
|
|
367
|
+
* path (delegation + batching + name normalization), so the collapsed
|
|
368
|
+
* node behaves byte-identically to the 5-layer mount it replaced.
|
|
369
|
+
*
|
|
370
|
+
* @param handlers `{ onClick: fn, onPointerEnter: fn, … }` — the peeled
|
|
371
|
+
* residual handlers; compiler PR 3 emits this object literal from the
|
|
372
|
+
* sliced source spans `detectPartialCollapsibleShape` returned.
|
|
373
|
+
*/
|
|
374
|
+
declare function _rsCollapseH(html: string, lightClass: string, darkClass: string, isDark: () => boolean, handlers: Record<string, unknown>, bind?: ((el: HTMLElement) => (() => void) | null) | null): NativeItem;
|
|
321
375
|
/**
|
|
322
376
|
* Mount a children slot inside a template.
|
|
323
377
|
*
|
|
@@ -459,5 +513,5 @@ declare function mount(root: VNodeChild, container: Element): () => void;
|
|
|
459
513
|
/** Alias for `mount` */
|
|
460
514
|
declare const render: typeof mount;
|
|
461
515
|
//#endregion
|
|
462
|
-
export { DELEGATED_EVENTS, type DevtoolsComponentEntry, type HydrationMismatchContext, type HydrationMismatchHandler, type HydrationMismatchType, KeepAlive, type KeepAliveProps, type PyreonDevtools, type SanitizeFn, Transition, TransitionGroup, type TransitionGroupProps, type TransitionProps, applyProps as _applyProps, applyProps, _bindDirect, _bindText, _mountSlot, _tpl, applyProp, createTemplate, delegatedPropName, disableHydrationWarnings, enableHydrationWarnings, hydrateRoot, mount, mountChild, onHydrationMismatch, render, sanitizeHtml, setSanitizer, setupDelegation };
|
|
516
|
+
export { DELEGATED_EVENTS, type DevtoolsComponentEntry, type HydrationMismatchContext, type HydrationMismatchHandler, type HydrationMismatchType, KeepAlive, type KeepAliveProps, type PyreonDevtools, type SanitizeFn, Transition, TransitionGroup, type TransitionGroupProps, type TransitionProps, applyProps as _applyProps, applyProps, _bindDirect, _bindText, _mountSlot, _rsCollapse, _rsCollapseH, _tpl, applyProp, createTemplate, delegatedPropName, disableHydrationWarnings, enableHydrationWarnings, hydrateRoot, mount, mountChild, onHydrationMismatch, render, sanitizeHtml, setSanitizer, setupDelegation };
|
|
463
517
|
//# sourceMappingURL=index2.d.ts.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pyreon/runtime-dom",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.0",
|
|
4
4
|
"description": "DOM renderer for Pyreon",
|
|
5
5
|
"homepage": "https://github.com/pyreon/pyreon/tree/main/packages/runtime-dom#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -54,15 +54,15 @@
|
|
|
54
54
|
"prepublishOnly": "bun run build"
|
|
55
55
|
},
|
|
56
56
|
"dependencies": {
|
|
57
|
-
"@pyreon/core": "^0.
|
|
58
|
-
"@pyreon/reactivity": "^0.
|
|
57
|
+
"@pyreon/core": "^0.21.0",
|
|
58
|
+
"@pyreon/reactivity": "^0.21.0"
|
|
59
59
|
},
|
|
60
60
|
"devDependencies": {
|
|
61
61
|
"@happy-dom/global-registrator": "^20.8.9",
|
|
62
|
-
"@pyreon/compiler": "^0.
|
|
62
|
+
"@pyreon/compiler": "^0.21.0",
|
|
63
63
|
"@pyreon/manifest": "0.13.1",
|
|
64
|
-
"@pyreon/runtime-server": "^0.
|
|
65
|
-
"@pyreon/test-utils": "^0.13.
|
|
64
|
+
"@pyreon/runtime-server": "^0.21.0",
|
|
65
|
+
"@pyreon/test-utils": "^0.13.8",
|
|
66
66
|
"@vitest/browser-playwright": "^4.1.4",
|
|
67
67
|
"esbuild": "^0.28.0",
|
|
68
68
|
"happy-dom": "^20.8.3",
|
package/src/devtools.ts
CHANGED
|
@@ -12,8 +12,19 @@
|
|
|
12
12
|
* window.__PYREON_DEVTOOLS__.onComponentMount(cb) // subscribe to mount events
|
|
13
13
|
* window.__PYREON_DEVTOOLS__.onComponentUnmount(cb)// subscribe to unmount events
|
|
14
14
|
* window.__PYREON_DEVTOOLS__.enableOverlay() // Ctrl+Shift+P: hover to inspect components
|
|
15
|
+
* window.__PYREON_DEVTOOLS__.reactive.activate() // opt-in: track the live signal/effect graph
|
|
16
|
+
* window.__PYREON_DEVTOOLS__.reactive.getGraph() // snapshot of signals/derived/effects + edges
|
|
15
17
|
*/
|
|
16
18
|
|
|
19
|
+
import {
|
|
20
|
+
activateReactiveDevtools,
|
|
21
|
+
deactivateReactiveDevtools,
|
|
22
|
+
getReactiveFires,
|
|
23
|
+
getReactiveGraph,
|
|
24
|
+
type ReactiveFire,
|
|
25
|
+
type ReactiveGraph,
|
|
26
|
+
} from '@pyreon/reactivity'
|
|
27
|
+
|
|
17
28
|
export interface DevtoolsComponentEntry {
|
|
18
29
|
id: string
|
|
19
30
|
name: string
|
|
@@ -33,6 +44,23 @@ export interface PyreonDevtools {
|
|
|
33
44
|
/** Toggle the component inspector overlay (also: Ctrl+Shift+P) */
|
|
34
45
|
enableOverlay(): void
|
|
35
46
|
disableOverlay(): void
|
|
47
|
+
/**
|
|
48
|
+
* Reactive-graph bridge — powers the devtools Signals / Graph / Effects
|
|
49
|
+
* surfaces. Opt-in and zero-cost until `activate()` is called: nothing
|
|
50
|
+
* is tracked while a devtools client is not attached.
|
|
51
|
+
*/
|
|
52
|
+
reactive: PyreonReactiveDevtools
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface PyreonReactiveDevtools {
|
|
56
|
+
/** Start tracking the live signal/computed/effect graph. Idempotent. */
|
|
57
|
+
activate(): void
|
|
58
|
+
/** Stop tracking + drop all retained registry/timeline state. */
|
|
59
|
+
deactivate(): void
|
|
60
|
+
/** Fresh snapshot of the reactive graph (nodes + edges). */
|
|
61
|
+
getGraph(): ReactiveGraph
|
|
62
|
+
/** Bounded recent-fire timeline (oldest → newest). */
|
|
63
|
+
getFires(): ReactiveFire[]
|
|
36
64
|
}
|
|
37
65
|
|
|
38
66
|
// ─── Internal registry ────────────────────────────────────────────────────────
|
|
@@ -250,6 +278,13 @@ export function installDevTools(): void {
|
|
|
250
278
|
|
|
251
279
|
enableOverlay,
|
|
252
280
|
disableOverlay,
|
|
281
|
+
|
|
282
|
+
reactive: {
|
|
283
|
+
activate: activateReactiveDevtools,
|
|
284
|
+
deactivate: deactivateReactiveDevtools,
|
|
285
|
+
getGraph: getReactiveGraph,
|
|
286
|
+
getFires: getReactiveFires,
|
|
287
|
+
},
|
|
253
288
|
}
|
|
254
289
|
|
|
255
290
|
// Attach to window — compatible with browser devtools extensions
|
package/src/index.ts
CHANGED
|
@@ -24,7 +24,15 @@ export {
|
|
|
24
24
|
sanitizeHtml,
|
|
25
25
|
setSanitizer,
|
|
26
26
|
} from './props'
|
|
27
|
-
export {
|
|
27
|
+
export {
|
|
28
|
+
_bindDirect,
|
|
29
|
+
_bindText,
|
|
30
|
+
_mountSlot,
|
|
31
|
+
_rsCollapse,
|
|
32
|
+
_rsCollapseH,
|
|
33
|
+
_tpl,
|
|
34
|
+
createTemplate,
|
|
35
|
+
} from './template'
|
|
28
36
|
export type { TransitionProps } from './transition'
|
|
29
37
|
export { Transition } from './transition'
|
|
30
38
|
export type { TransitionGroupProps } from './transition-group'
|
package/src/manifest.ts
CHANGED
|
@@ -192,6 +192,24 @@ setSanitizer(DOMPurify.sanitize)
|
|
|
192
192
|
const clean = sanitizeHtml(userInput)`,
|
|
193
193
|
seeAlso: ['setSanitizer'],
|
|
194
194
|
},
|
|
195
|
+
{
|
|
196
|
+
name: '__PYREON_DEVTOOLS__',
|
|
197
|
+
kind: 'constant',
|
|
198
|
+
signature:
|
|
199
|
+
'window.__PYREON_DEVTOOLS__: { version; getComponentTree(); getAllComponents(); highlight(id); onComponentMount(cb); onComponentUnmount(cb); enableOverlay(); disableOverlay(); reactive: PyreonReactiveDevtools }',
|
|
200
|
+
summary:
|
|
201
|
+
'Browser devtools hook, installed automatically on the first `mount()` (no-op on the server). Exposes the component tree + an element-picker overlay (also `Ctrl+Shift+P`) for the `@pyreon/devtools` Chrome extension, plus a `$p` console helper. The `reactive` namespace bridges `@pyreon/reactivity`’s opt-in graph: `reactive.activate()` / `deactivate()` start/stop tracking, `reactive.getGraph()` returns the live signal/computed/effect nodes + dependency edges, `reactive.getFires()` the bounded fire timeline — powering the extension’s Signals / Graph / Effects / Profiler / Console tabs. **Dev-only and tree-shaken from production builds**; `reactive` is zero-cost until `activate()` is called by an attached panel.',
|
|
202
|
+
example: `// In the browser console (after the app has mounted):
|
|
203
|
+
$p.tree() // root component entries
|
|
204
|
+
window.__PYREON_DEVTOOLS__.reactive.activate()
|
|
205
|
+
window.__PYREON_DEVTOOLS__.reactive.getGraph() // { nodes, edges }`,
|
|
206
|
+
mistakes: [
|
|
207
|
+
'Reading it before the first `mount()` — it is installed by mount; it is `undefined` until then (and always `undefined` on the server / in production builds)',
|
|
208
|
+
'Expecting `reactive.getGraph()` to return data without calling `reactive.activate()` first — tracking is opt-in (zero-cost until a panel attaches)',
|
|
209
|
+
'Depending on it in app code — it is a dev-tooling hook, tree-shaken in production; never branch runtime behavior on its presence',
|
|
210
|
+
],
|
|
211
|
+
seeAlso: ['mount'],
|
|
212
|
+
},
|
|
195
213
|
],
|
|
196
214
|
gotchas: [
|
|
197
215
|
{
|
package/src/props.ts
CHANGED
|
@@ -264,6 +264,22 @@ function applyEventProp(el: Element, key: string, value: unknown): Cleanup | nul
|
|
|
264
264
|
return () => el.removeEventListener(eventName, batched)
|
|
265
265
|
}
|
|
266
266
|
|
|
267
|
+
/**
|
|
268
|
+
* Bind ONE event handler through the CANONICAL event path
|
|
269
|
+
* (`applyEventProp` — the same delegation, batching, and exact
|
|
270
|
+
* `onXxx`→event-name normalization every compiler-emitted handler
|
|
271
|
+
* uses). PR 2 of the partial-collapse build (open-work #1): a
|
|
272
|
+
* collapsed-with-handler site (`_rsCollapseH`) re-attaches the residual
|
|
273
|
+
* handlers `detectPartialCollapsibleShape` (compiler PR 1) peeled off.
|
|
274
|
+
* Contract-consistent BY CONSTRUCTION — it IS `applyEventProp`, not a
|
|
275
|
+
* re-implementation — so a partially-collapsed `<Button onClick=…>`
|
|
276
|
+
* behaves byte-identically to the 5-layer mount it replaced (same
|
|
277
|
+
* delegated-event prop slot, same `batch()` wrapping, same cleanup).
|
|
278
|
+
*/
|
|
279
|
+
export function _bindEvent(el: Element, key: string, handler: unknown): Cleanup | null {
|
|
280
|
+
return applyEventProp(el, key, handler)
|
|
281
|
+
}
|
|
282
|
+
|
|
267
283
|
/**
|
|
268
284
|
* Sink for a single prop's CALLED value (always a primitive / object /
|
|
269
285
|
* `null` — never a function). Called both directly for static values and
|
package/src/template.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { NativeItem, VNodeChild } from '@pyreon/core'
|
|
2
2
|
import { renderEffect } from '@pyreon/reactivity'
|
|
3
3
|
import { mountChild } from './mount'
|
|
4
|
+
import { _bindEvent } from './props'
|
|
4
5
|
|
|
5
6
|
// Dev-mode gate: see `pyreon/no-process-dev-gate` lint rule for why this
|
|
6
7
|
// uses `import.meta.env.DEV` instead of `typeof process !== 'undefined'`.
|
|
@@ -193,6 +194,105 @@ export function _tpl(html: string, bind: (el: HTMLElement) => (() => void) | nul
|
|
|
193
194
|
return { __isNative: true, el, cleanup }
|
|
194
195
|
}
|
|
195
196
|
|
|
197
|
+
/**
|
|
198
|
+
* Compiler-emitted collapsed rocketstyle call site.
|
|
199
|
+
*
|
|
200
|
+
* The runtime half of the P0 compile-time rocketstyle wrapper-collapse.
|
|
201
|
+
* For a literal-prop call site like `<Button state="primary" size="md">Save</Button>`,
|
|
202
|
+
* the build resolves the FULL rocketstyle/styler pipeline once (SSR
|
|
203
|
+
* render of the real component) and the compiler emits ONE `_rsCollapse`
|
|
204
|
+
* call instead of the 5-layer wrapper mount (rocketstyle → attrs HOC →
|
|
205
|
+
* Element → Wrapper → styled). Measured 44× wall-clock, mountChild 9→1
|
|
206
|
+
* (see examples/experiments/e2-static-rocketstyle/RESULTS.md).
|
|
207
|
+
*
|
|
208
|
+
* Dual-emit (RFC decision 1): both the light- and dark-resolved class
|
|
209
|
+
* strings are baked in; `isDark` is the app's live mode accessor (the
|
|
210
|
+
* compiler threads it from the configured provider, e.g. `useMode` from
|
|
211
|
+
* `@pyreon/ui-core`). A whole-theme/mode swap re-runs only this binding —
|
|
212
|
+
* no remount — preserving Pyreon's reactive mode-switch contract. The
|
|
213
|
+
* resolved CSS rules are injected once at module-eval via the styler's
|
|
214
|
+
* idempotent `injectRules()` (emitted alongside this call), so the
|
|
215
|
+
* collapsed site is self-sufficient: no prior runtime mount of the real
|
|
216
|
+
* component is needed to populate the sheet.
|
|
217
|
+
*
|
|
218
|
+
* `bind` is the standard `_tpl` child/event binder for the (static)
|
|
219
|
+
* children — identical to what the compiler emits for the non-collapsed
|
|
220
|
+
* template path, so children reactivity / event delegation is unchanged.
|
|
221
|
+
*
|
|
222
|
+
* @param html static element HTML WITHOUT the class attr (class is applied reactively)
|
|
223
|
+
* @param lightClass resolved styler class string for light mode
|
|
224
|
+
* @param darkClass resolved styler class string for dark mode
|
|
225
|
+
* @param isDark app mode accessor — `() => boolean` (true ⇒ dark)
|
|
226
|
+
* @param bind standard _tpl binder for children/events (or null)
|
|
227
|
+
*/
|
|
228
|
+
export function _rsCollapse(
|
|
229
|
+
html: string,
|
|
230
|
+
lightClass: string,
|
|
231
|
+
darkClass: string,
|
|
232
|
+
isDark: () => boolean,
|
|
233
|
+
bind?: ((el: HTMLElement) => (() => void) | null) | null,
|
|
234
|
+
): NativeItem {
|
|
235
|
+
return _tpl(html, (el) => {
|
|
236
|
+
// Reactive class: _bindDirect's plain-callable fallback wraps this in
|
|
237
|
+
// a renderEffect, so reading the mode accessor subscribes to the live
|
|
238
|
+
// mode signal — a mode swap re-runs ONLY this className assignment.
|
|
239
|
+
const disposeClass = _bindDirect(isDark as unknown as { _v?: unknown }, (v) => {
|
|
240
|
+
el.className = v ? darkClass : lightClass
|
|
241
|
+
})
|
|
242
|
+
const disposeChildren = bind ? bind(el) : null
|
|
243
|
+
if (!disposeChildren) return disposeClass
|
|
244
|
+
return () => {
|
|
245
|
+
disposeClass()
|
|
246
|
+
disposeChildren()
|
|
247
|
+
}
|
|
248
|
+
})
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Compiler-emitted PARTIALLY-collapsed rocketstyle call site — PR 2 of
|
|
253
|
+
* the partial-collapse build (`.claude/plans/open-work-2026-q3.md` → #1).
|
|
254
|
+
*
|
|
255
|
+
* Identical to {@link _rsCollapse} (one `_tpl` cloneNode, dual-emit
|
|
256
|
+
* reactive class, no remount on mode swap) PLUS it re-attaches the
|
|
257
|
+
* residual event handlers `detectPartialCollapsibleShape` (compiler
|
|
258
|
+
* PR 1) peeled off the `on*`-handler-only subset (the 7.8% the bail
|
|
259
|
+
* census measured). Handlers are orthogonal to the SSR-resolved styler
|
|
260
|
+
* class, so `html` / `lightClass` / `darkClass` are byte-identical to a
|
|
261
|
+
* full-collapse site's — the ONLY delta vs `_rsCollapse` is the handler
|
|
262
|
+
* re-attach, routed through the CANONICAL `_bindEvent` → `applyEventProp`
|
|
263
|
+
* path (delegation + batching + name normalization), so the collapsed
|
|
264
|
+
* node behaves byte-identically to the 5-layer mount it replaced.
|
|
265
|
+
*
|
|
266
|
+
* @param handlers `{ onClick: fn, onPointerEnter: fn, … }` — the peeled
|
|
267
|
+
* residual handlers; compiler PR 3 emits this object literal from the
|
|
268
|
+
* sliced source spans `detectPartialCollapsibleShape` returned.
|
|
269
|
+
*/
|
|
270
|
+
export function _rsCollapseH(
|
|
271
|
+
html: string,
|
|
272
|
+
lightClass: string,
|
|
273
|
+
darkClass: string,
|
|
274
|
+
isDark: () => boolean,
|
|
275
|
+
handlers: Record<string, unknown>,
|
|
276
|
+
bind?: ((el: HTMLElement) => (() => void) | null) | null,
|
|
277
|
+
): NativeItem {
|
|
278
|
+
return _tpl(html, (el) => {
|
|
279
|
+
const disposeClass = _bindDirect(isDark as unknown as { _v?: unknown }, (v) => {
|
|
280
|
+
el.className = v ? darkClass : lightClass
|
|
281
|
+
})
|
|
282
|
+
const handlerDisposers: (() => void)[] = []
|
|
283
|
+
for (const key in handlers) {
|
|
284
|
+
const d = _bindEvent(el, key, handlers[key])
|
|
285
|
+
if (d) handlerDisposers.push(d)
|
|
286
|
+
}
|
|
287
|
+
const disposeChildren = bind ? bind(el) : null
|
|
288
|
+
return () => {
|
|
289
|
+
disposeClass()
|
|
290
|
+
for (const d of handlerDisposers) d()
|
|
291
|
+
if (disposeChildren) disposeChildren()
|
|
292
|
+
}
|
|
293
|
+
})
|
|
294
|
+
}
|
|
295
|
+
|
|
196
296
|
/**
|
|
197
297
|
* Test-only: clear the template cache. Used by tests that assert on
|
|
198
298
|
* cache size; never called by runtime code. Not exported from the
|
|
@@ -74,7 +74,8 @@ describe('gen-docs — runtime-dom snapshot', () => {
|
|
|
74
74
|
|
|
75
75
|
it('renders @pyreon/runtime-dom to MCP api-reference entries — one per api[] item', () => {
|
|
76
76
|
const record = renderApiReferenceEntries(runtimeDomManifest)
|
|
77
|
-
|
|
77
|
+
// +1: __PYREON_DEVTOOLS__ (reactive-devtools hook surface).
|
|
78
|
+
expect(Object.keys(record).length).toBe(10)
|
|
78
79
|
expect(Object.keys(record)).toContain('runtime-dom/mount')
|
|
79
80
|
// Spot-check the flagship API — mount is the primary entry point
|
|
80
81
|
const mount = record['runtime-dom/mount']!
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { signal } from '@pyreon/reactivity'
|
|
2
|
+
import { flush } from '@pyreon/test-utils/browser'
|
|
3
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
4
|
+
import { _rsCollapseH, mount } from '../index'
|
|
5
|
+
|
|
6
|
+
// PR 2 of the partial-collapse build (open-work #1), in REAL Chromium.
|
|
7
|
+
// `_rsCollapseH` = `_rsCollapse` (one _tpl cloneNode, dual-emit reactive
|
|
8
|
+
// class, no remount) PLUS it re-attaches the residual `on*` handlers the
|
|
9
|
+
// compiler's `detectPartialCollapsibleShape` (PR 1) peeled off. The ONLY
|
|
10
|
+
// delta vs `_rsCollapse` is the handler re-attach, routed through the
|
|
11
|
+
// canonical `_bindEvent` → `applyEventProp` path — so a partially-
|
|
12
|
+
// collapsed `<Button onClick=…>` behaves byte-identically to the
|
|
13
|
+
// 5-layer mount it replaced. These specs prove exactly that delta in a
|
|
14
|
+
// real browser (real click/pointer events, real computed style).
|
|
15
|
+
//
|
|
16
|
+
// Bisect-verify (PR body): neutralize the handler loop in
|
|
17
|
+
// `_rsCollapseH` (`for (const key in handlers)` → no-op) → the 3
|
|
18
|
+
// handler specs fail (`expected 0 to be 1` — handler never fired) while
|
|
19
|
+
// every class/mode/no-remount assertion still passes; restore → all
|
|
20
|
+
// pass. That asymmetry proves the handler re-attach is the load-bearing
|
|
21
|
+
// addition, not passing for the wrong reason.
|
|
22
|
+
|
|
23
|
+
describe('_rsCollapseH (real browser) — PR 2 partial-collapse runtime', () => {
|
|
24
|
+
const cleanup: Array<() => void> = []
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
for (const u of cleanup.splice(0)) u()
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
function injectCss(css: string): void {
|
|
30
|
+
const el = document.createElement('style')
|
|
31
|
+
el.textContent = css
|
|
32
|
+
document.head.appendChild(el)
|
|
33
|
+
cleanup.push(() => el.remove())
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function mountInto(node: ReturnType<typeof _rsCollapseH>): HTMLElement {
|
|
37
|
+
const root = document.createElement('div')
|
|
38
|
+
document.body.appendChild(root)
|
|
39
|
+
const dispose = mount(node as unknown as Parameters<typeof mount>[0], root)
|
|
40
|
+
cleanup.push(() => {
|
|
41
|
+
dispose()
|
|
42
|
+
root.remove()
|
|
43
|
+
})
|
|
44
|
+
return root
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
it('applies the light class AND fires the peeled onClick on a real click', async () => {
|
|
48
|
+
injectCss('.rsh-l{color:rgb(1,2,3)}.rsh-d{color:rgb(9,8,7)}')
|
|
49
|
+
const isDark = signal(false)
|
|
50
|
+
let clicks = 0
|
|
51
|
+
const root = mountInto(
|
|
52
|
+
_rsCollapseH('<button>Save</button>', 'rsh-l', 'rsh-d', () => isDark(), {
|
|
53
|
+
onClick: () => {
|
|
54
|
+
clicks++
|
|
55
|
+
},
|
|
56
|
+
}),
|
|
57
|
+
)
|
|
58
|
+
await flush()
|
|
59
|
+
const btn = root.querySelector('button') as HTMLButtonElement
|
|
60
|
+
expect(btn).not.toBeNull()
|
|
61
|
+
expect(btn.className).toBe('rsh-l')
|
|
62
|
+
expect(btn.textContent).toBe('Save')
|
|
63
|
+
expect(getComputedStyle(btn).color).toBe('rgb(1, 2, 3)')
|
|
64
|
+
|
|
65
|
+
btn.click()
|
|
66
|
+
expect(clicks).toBe(1)
|
|
67
|
+
btn.click()
|
|
68
|
+
expect(clicks).toBe(2)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('mode flip swaps the class on the SAME node AND the handler survives the flip', async () => {
|
|
72
|
+
injectCss('.rsh-l2{color:rgb(10,20,30)}.rsh-d2{color:rgb(40,50,60)}')
|
|
73
|
+
const isDark = signal(false)
|
|
74
|
+
let clicks = 0
|
|
75
|
+
const root = mountInto(
|
|
76
|
+
_rsCollapseH('<button>X</button>', 'rsh-l2', 'rsh-d2', () => isDark(), {
|
|
77
|
+
onClick: () => {
|
|
78
|
+
clicks++
|
|
79
|
+
},
|
|
80
|
+
}),
|
|
81
|
+
)
|
|
82
|
+
await flush()
|
|
83
|
+
const before = root.querySelector('button') as HTMLButtonElement
|
|
84
|
+
expect(before.className).toBe('rsh-l2')
|
|
85
|
+
before.click()
|
|
86
|
+
expect(clicks).toBe(1)
|
|
87
|
+
|
|
88
|
+
isDark.set(true)
|
|
89
|
+
await flush()
|
|
90
|
+
const after = root.querySelector('button') as HTMLButtonElement
|
|
91
|
+
expect(after).toBe(before) // node identity preserved ⇒ reactive, not remount
|
|
92
|
+
expect(after.className).toBe('rsh-d2')
|
|
93
|
+
// The load-bearing partial-collapse contract: the reactive class
|
|
94
|
+
// binding does NOT remount, so the handler attached at first mount is
|
|
95
|
+
// still live after the mode flip.
|
|
96
|
+
after.click()
|
|
97
|
+
expect(clicks).toBe(2)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('peels + binds MULTIPLE handlers with correct onXxx→event normalization', async () => {
|
|
101
|
+
injectCss('.rsh-m{color:rgb(0,0,0)}')
|
|
102
|
+
const isDark = signal(false)
|
|
103
|
+
let clicks = 0
|
|
104
|
+
let enters = 0
|
|
105
|
+
const root = mountInto(
|
|
106
|
+
_rsCollapseH('<button>M</button>', 'rsh-m', 'rsh-m', () => isDark(), {
|
|
107
|
+
onClick: () => {
|
|
108
|
+
clicks++
|
|
109
|
+
},
|
|
110
|
+
// onPointerEnter must normalize to the lowercase DOM event
|
|
111
|
+
// `pointerenter` via the canonical path — a hand-rolled
|
|
112
|
+
// `addEventListener('pointerEnter', …)` would never fire.
|
|
113
|
+
onPointerEnter: () => {
|
|
114
|
+
enters++
|
|
115
|
+
},
|
|
116
|
+
}),
|
|
117
|
+
)
|
|
118
|
+
await flush()
|
|
119
|
+
const btn = root.querySelector('button') as HTMLButtonElement
|
|
120
|
+
btn.click()
|
|
121
|
+
btn.dispatchEvent(new PointerEvent('pointerenter', { bubbles: false }))
|
|
122
|
+
expect(clicks).toBe(1)
|
|
123
|
+
expect(enters).toBe(1)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('dispose removes the listener (no leak) — composed cleanup is correct', async () => {
|
|
127
|
+
injectCss('.rsh-c{color:rgb(5,5,5)}')
|
|
128
|
+
const isDark = signal(false)
|
|
129
|
+
let clicks = 0
|
|
130
|
+
const root = document.createElement('div')
|
|
131
|
+
document.body.appendChild(root)
|
|
132
|
+
const dispose = mount(
|
|
133
|
+
_rsCollapseH('<button>C</button>', 'rsh-c', 'rsh-c', () => isDark(), {
|
|
134
|
+
onClick: () => {
|
|
135
|
+
clicks++
|
|
136
|
+
},
|
|
137
|
+
}) as unknown as Parameters<typeof mount>[0],
|
|
138
|
+
root,
|
|
139
|
+
)
|
|
140
|
+
await flush()
|
|
141
|
+
const btn = root.querySelector('button') as HTMLButtonElement
|
|
142
|
+
btn.click()
|
|
143
|
+
expect(clicks).toBe(1)
|
|
144
|
+
|
|
145
|
+
dispose()
|
|
146
|
+
// After dispose the handler disposer ran — the listener is gone, so a
|
|
147
|
+
// post-dispose click does NOT increment.
|
|
148
|
+
btn.click()
|
|
149
|
+
expect(clicks).toBe(1)
|
|
150
|
+
root.remove()
|
|
151
|
+
})
|
|
152
|
+
})
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { signal } from '@pyreon/reactivity'
|
|
2
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
3
|
+
import { _rsCollapseH } from '../template'
|
|
4
|
+
|
|
5
|
+
// PR 2 of the partial-collapse build (open-work #1) — happy-dom UNIT
|
|
6
|
+
// layer (fast + locally bisect-verifiable). Imports ONLY `../template`
|
|
7
|
+
// (template.test.ts style) — NOT `../index`, whose wide cross-package
|
|
8
|
+
// re-export graph hits the documented fresh-worktree resolution trap.
|
|
9
|
+
//
|
|
10
|
+
// Event-path split (honest, per test-environment-parity — both layers):
|
|
11
|
+
// - DELEGATED events (`click`, see `delegate.ts:DELEGATED_EVENTS`)
|
|
12
|
+
// park in a prop-slot that only fires via the root listener
|
|
13
|
+
// `mount()` installs → covered by `rs-collapse-h.browser.test.ts`
|
|
14
|
+
// (real Chromium + real `mount()`, CI-authoritative; can't run in a
|
|
15
|
+
// fresh worktree — `@pyreon/test-utils/browser` needs built lib).
|
|
16
|
+
// - NON-delegated events (`pointerenter`, `mouseenter` — NOT in
|
|
17
|
+
// `DELEGATED_EVENTS`) take `applyEventProp`'s direct
|
|
18
|
+
// `el.addEventListener(name, …)` path → fire on a bare
|
|
19
|
+
// `dispatchEvent` with NO `mount()`. This unit drives the
|
|
20
|
+
// handler-attach delta on THAT path: it still proves `_rsCollapseH`
|
|
21
|
+
// routes residual handlers through the canonical
|
|
22
|
+
// `_bindEvent`→`applyEventProp` path (incl. the exact
|
|
23
|
+
// `onXxx`→lowercase normalization) + composed cleanup — the
|
|
24
|
+
// load-bearing addition vs `_rsCollapse`.
|
|
25
|
+
//
|
|
26
|
+
// `_rsCollapseH` returns a NativeItem (`{ __isNative, el, cleanup }`);
|
|
27
|
+
// `_tpl` runs the bind synchronously at call time, so class + handlers
|
|
28
|
+
// are live on `.el` immediately (same direct-NativeItem shape
|
|
29
|
+
// `template.test.ts` uses for `_tpl`). Signal writes propagate
|
|
30
|
+
// synchronously here (cf. `template.test.ts:_bindText`); a
|
|
31
|
+
// `Promise.resolve()` tick covers any microtask-scheduled reactive
|
|
32
|
+
// class update defensively.
|
|
33
|
+
//
|
|
34
|
+
// Bisect-verify (PR body): neutralize the handler loop in
|
|
35
|
+
// `_rsCollapseH` (`for (const key in handlers)` body → skip) → the
|
|
36
|
+
// handler/normalization/cleanup specs fail with `expected 0 to be 1`
|
|
37
|
+
// while every class / mode-flip / no-remount / zero-handler / child
|
|
38
|
+
// assertion still passes; restore → 8/8. The asymmetry proves the
|
|
39
|
+
// handler re-attach is the load-bearing delta.
|
|
40
|
+
|
|
41
|
+
const tick = (): Promise<void> => Promise.resolve()
|
|
42
|
+
|
|
43
|
+
describe('_rsCollapseH (happy-dom unit) — PR 2 partial-collapse runtime', () => {
|
|
44
|
+
const cleanup: Array<() => void> = []
|
|
45
|
+
afterEach(() => {
|
|
46
|
+
for (const u of cleanup.splice(0)) u()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
function place(item: ReturnType<typeof _rsCollapseH>): HTMLElement {
|
|
50
|
+
const el = item.el as HTMLElement
|
|
51
|
+
document.body.appendChild(el)
|
|
52
|
+
cleanup.push(() => {
|
|
53
|
+
item.cleanup?.()
|
|
54
|
+
el.remove()
|
|
55
|
+
})
|
|
56
|
+
return el
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const fire = (el: HTMLElement, type: string): void => {
|
|
60
|
+
el.dispatchEvent(new Event(type, { bubbles: false }))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
it('sets the light class + static children AND fires the peeled handler', () => {
|
|
64
|
+
const isDark = signal(false)
|
|
65
|
+
let enters = 0
|
|
66
|
+
const el = place(
|
|
67
|
+
_rsCollapseH('<button>Save</button>', 'rsh-l', 'rsh-d', () => isDark(), {
|
|
68
|
+
onPointerEnter: () => {
|
|
69
|
+
enters++
|
|
70
|
+
},
|
|
71
|
+
}),
|
|
72
|
+
)
|
|
73
|
+
expect(el.tagName).toBe('BUTTON')
|
|
74
|
+
expect(el.className).toBe('rsh-l')
|
|
75
|
+
expect(el.textContent).toBe('Save')
|
|
76
|
+
|
|
77
|
+
fire(el, 'pointerenter')
|
|
78
|
+
expect(enters).toBe(1)
|
|
79
|
+
fire(el, 'pointerenter')
|
|
80
|
+
expect(enters).toBe(2)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('normalizes onXxx → lowercase DOM event via the canonical path', () => {
|
|
84
|
+
const isDark = signal(false)
|
|
85
|
+
let enters = 0
|
|
86
|
+
let mouse = 0
|
|
87
|
+
const el = place(
|
|
88
|
+
_rsCollapseH('<button>M</button>', 'rsh-m', 'rsh-m', () => isDark(), {
|
|
89
|
+
// onPointerEnter MUST bind to `pointerenter` (lowercased whole
|
|
90
|
+
// name) — a hand-rolled `addEventListener('pointerEnter', …)`
|
|
91
|
+
// would never fire. onMouseEnter → `mouseenter` likewise.
|
|
92
|
+
onPointerEnter: () => {
|
|
93
|
+
enters++
|
|
94
|
+
},
|
|
95
|
+
onMouseEnter: () => {
|
|
96
|
+
mouse++
|
|
97
|
+
},
|
|
98
|
+
}),
|
|
99
|
+
)
|
|
100
|
+
fire(el, 'pointerenter')
|
|
101
|
+
fire(el, 'mouseenter')
|
|
102
|
+
expect(enters).toBe(1)
|
|
103
|
+
expect(mouse).toBe(1)
|
|
104
|
+
// Wrong-case names must NOT fire (proves real normalization, not luck).
|
|
105
|
+
fire(el, 'pointerEnter')
|
|
106
|
+
fire(el, 'mouseEnter')
|
|
107
|
+
expect(enters).toBe(1)
|
|
108
|
+
expect(mouse).toBe(1)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('mode flip swaps the class on the SAME node AND the handler survives the flip', async () => {
|
|
112
|
+
const isDark = signal(false)
|
|
113
|
+
let enters = 0
|
|
114
|
+
const el = place(
|
|
115
|
+
_rsCollapseH('<button>X</button>', 'rsh-l2', 'rsh-d2', () => isDark(), {
|
|
116
|
+
onPointerEnter: () => {
|
|
117
|
+
enters++
|
|
118
|
+
},
|
|
119
|
+
}),
|
|
120
|
+
)
|
|
121
|
+
expect(el.className).toBe('rsh-l2')
|
|
122
|
+
fire(el, 'pointerenter')
|
|
123
|
+
expect(enters).toBe(1)
|
|
124
|
+
|
|
125
|
+
isDark.set(true)
|
|
126
|
+
await tick()
|
|
127
|
+
// Same node identity ⇒ reactive class swap, NOT a remount.
|
|
128
|
+
expect(el.className).toBe('rsh-d2')
|
|
129
|
+
// Load-bearing partial-collapse contract: no remount ⇒ the handler
|
|
130
|
+
// bound at construction is still live after the mode flip.
|
|
131
|
+
fire(el, 'pointerenter')
|
|
132
|
+
expect(enters).toBe(2)
|
|
133
|
+
|
|
134
|
+
isDark.set(false)
|
|
135
|
+
await tick()
|
|
136
|
+
expect(el.className).toBe('rsh-l2')
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('cleanup() removes the listener — composed disposer is correct (no leak)', () => {
|
|
140
|
+
const isDark = signal(false)
|
|
141
|
+
let enters = 0
|
|
142
|
+
const item = _rsCollapseH('<button>C</button>', 'rsh-c', 'rsh-c', () => isDark(), {
|
|
143
|
+
onPointerEnter: () => {
|
|
144
|
+
enters++
|
|
145
|
+
},
|
|
146
|
+
})
|
|
147
|
+
const el = item.el as HTMLElement
|
|
148
|
+
document.body.appendChild(el)
|
|
149
|
+
fire(el, 'pointerenter')
|
|
150
|
+
expect(enters).toBe(1)
|
|
151
|
+
|
|
152
|
+
item.cleanup?.()
|
|
153
|
+
fire(el, 'pointerenter') // listener removed by the composed disposer
|
|
154
|
+
expect(enters).toBe(1)
|
|
155
|
+
el.remove()
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('binds MULTIPLE handlers; literal props stay out of the handler set', () => {
|
|
159
|
+
const isDark = signal(false)
|
|
160
|
+
let a = 0
|
|
161
|
+
let b = 0
|
|
162
|
+
const el = place(
|
|
163
|
+
_rsCollapseH('<button>N</button>', 'rsh-n', 'rsh-n', () => isDark(), {
|
|
164
|
+
onPointerEnter: () => {
|
|
165
|
+
a++
|
|
166
|
+
},
|
|
167
|
+
onMouseEnter: () => {
|
|
168
|
+
b++
|
|
169
|
+
},
|
|
170
|
+
}),
|
|
171
|
+
)
|
|
172
|
+
fire(el, 'pointerenter')
|
|
173
|
+
fire(el, 'mouseenter')
|
|
174
|
+
fire(el, 'pointerenter')
|
|
175
|
+
expect(a).toBe(2)
|
|
176
|
+
expect(b).toBe(1)
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('zero handlers: renders class + children, cleanup is safe (defensive)', async () => {
|
|
180
|
+
const isDark = signal(false)
|
|
181
|
+
const el = place(_rsCollapseH('<button>Z</button>', 'rsh-z', 'rsh-zd', () => isDark(), {}))
|
|
182
|
+
expect(el.className).toBe('rsh-z')
|
|
183
|
+
expect(el.textContent).toBe('Z')
|
|
184
|
+
isDark.set(true)
|
|
185
|
+
await tick()
|
|
186
|
+
expect(el.className).toBe('rsh-zd')
|
|
187
|
+
// cleanup runs via afterEach — must not throw with no handlers.
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('children bind runs alongside the class + handler binds', () => {
|
|
191
|
+
const isDark = signal(false)
|
|
192
|
+
let enters = 0
|
|
193
|
+
const el = place(
|
|
194
|
+
_rsCollapseH(
|
|
195
|
+
'<button><span></span></button>',
|
|
196
|
+
'rsh-cb',
|
|
197
|
+
'rsh-cbd',
|
|
198
|
+
() => isDark(),
|
|
199
|
+
{ onPointerEnter: () => enters++ },
|
|
200
|
+
(root) => {
|
|
201
|
+
;(root.querySelector('span') as HTMLElement).textContent = 'child'
|
|
202
|
+
return null
|
|
203
|
+
},
|
|
204
|
+
),
|
|
205
|
+
)
|
|
206
|
+
expect(el.className).toBe('rsh-cb')
|
|
207
|
+
expect((el.querySelector('span') as HTMLElement).textContent).toBe('child')
|
|
208
|
+
fire(el, 'pointerenter')
|
|
209
|
+
expect(enters).toBe(1)
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it('disposes class + handler + child binds together (composition)', () => {
|
|
213
|
+
const isDark = signal(false)
|
|
214
|
+
let enters = 0
|
|
215
|
+
let childDisposed = false
|
|
216
|
+
const item = _rsCollapseH(
|
|
217
|
+
'<button><span></span></button>',
|
|
218
|
+
'rsh-x',
|
|
219
|
+
'rsh-xd',
|
|
220
|
+
() => isDark(),
|
|
221
|
+
{ onPointerEnter: () => enters++ },
|
|
222
|
+
() => () => {
|
|
223
|
+
childDisposed = true
|
|
224
|
+
},
|
|
225
|
+
)
|
|
226
|
+
const el = item.el as HTMLElement
|
|
227
|
+
document.body.appendChild(el)
|
|
228
|
+
fire(el, 'pointerenter')
|
|
229
|
+
expect(enters).toBe(1)
|
|
230
|
+
|
|
231
|
+
item.cleanup?.()
|
|
232
|
+
expect(childDisposed).toBe(true) // child disposer composed + ran
|
|
233
|
+
fire(el, 'pointerenter')
|
|
234
|
+
expect(enters).toBe(1) // handler disposer ran too
|
|
235
|
+
el.remove()
|
|
236
|
+
})
|
|
237
|
+
})
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { signal } from '@pyreon/reactivity'
|
|
2
|
+
import { flush } from '@pyreon/test-utils/browser'
|
|
3
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
4
|
+
import { _rsCollapse, mount } from '../index'
|
|
5
|
+
|
|
6
|
+
// Layer 2 of the P0 rocketstyle-collapse slice, in real Chromium.
|
|
7
|
+
// `_rsCollapse` deliberately does NOT import @pyreon/styler (layer
|
|
8
|
+
// purity — runtime-dom is layer 4; the styler injection is the EMITTED
|
|
9
|
+
// code's job). So this suite injects CSS via a plain <style> and proves
|
|
10
|
+
// only what _rsCollapse owns: ONE _tpl() cloneNode whose class is
|
|
11
|
+
// reactively bound to the app mode (dual-emit, RFC decision 1) — a mode
|
|
12
|
+
// flip swaps the class on the SAME node with no remount.
|
|
13
|
+
|
|
14
|
+
describe('_rsCollapse (real browser)', () => {
|
|
15
|
+
const cleanup: Array<() => void> = []
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
for (const u of cleanup.splice(0)) u()
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
function injectCss(css: string): void {
|
|
21
|
+
const el = document.createElement('style')
|
|
22
|
+
el.textContent = css
|
|
23
|
+
document.head.appendChild(el)
|
|
24
|
+
cleanup.push(() => el.remove())
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function mountInto(node: ReturnType<typeof _rsCollapse>): HTMLElement {
|
|
28
|
+
const root = document.createElement('div')
|
|
29
|
+
document.body.appendChild(root)
|
|
30
|
+
const dispose = mount(node as unknown as Parameters<typeof mount>[0], root)
|
|
31
|
+
cleanup.push(() => {
|
|
32
|
+
dispose()
|
|
33
|
+
root.remove()
|
|
34
|
+
})
|
|
35
|
+
return root
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
it('applies the light class + static children; the class is real CSS', async () => {
|
|
39
|
+
injectCss('.rsc-light{color:rgb(1,2,3)}.rsc-dark{color:rgb(9,8,7)}')
|
|
40
|
+
const isDark = signal(false)
|
|
41
|
+
const root = mountInto(
|
|
42
|
+
_rsCollapse('<button>Save</button>', 'rsc-light', 'rsc-dark', () => isDark()),
|
|
43
|
+
)
|
|
44
|
+
await flush()
|
|
45
|
+
const btn = root.querySelector('button')
|
|
46
|
+
expect(btn).not.toBeNull()
|
|
47
|
+
expect(btn?.className).toBe('rsc-light')
|
|
48
|
+
expect(btn?.textContent).toBe('Save')
|
|
49
|
+
expect(getComputedStyle(btn as Element).color).toBe('rgb(1, 2, 3)')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('mode flip swaps to the dark class on the SAME node (no remount)', async () => {
|
|
53
|
+
injectCss('.rsc-l2{color:rgb(10,20,30)}.rsc-d2{color:rgb(40,50,60)}')
|
|
54
|
+
const isDark = signal(false)
|
|
55
|
+
const root = mountInto(
|
|
56
|
+
_rsCollapse('<button>X</button>', 'rsc-l2', 'rsc-d2', () => isDark()),
|
|
57
|
+
)
|
|
58
|
+
await flush()
|
|
59
|
+
const before = root.querySelector('button') as HTMLElement
|
|
60
|
+
expect(before.className).toBe('rsc-l2')
|
|
61
|
+
|
|
62
|
+
isDark.set(true)
|
|
63
|
+
await flush()
|
|
64
|
+
const after = root.querySelector('button') as HTMLElement
|
|
65
|
+
expect(after).toBe(before) // node identity preserved ⇒ reactive, not remount
|
|
66
|
+
expect(after.className).toBe('rsc-d2')
|
|
67
|
+
expect(getComputedStyle(after).color).toBe('rgb(40, 50, 60)')
|
|
68
|
+
|
|
69
|
+
isDark.set(false)
|
|
70
|
+
await flush()
|
|
71
|
+
expect((root.querySelector('button') as HTMLElement).className).toBe('rsc-l2')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('children bind runs alongside the class bind and disposes cleanly', async () => {
|
|
75
|
+
injectCss('.rsc-c{color:rgb(2,2,2)}.rsc-cd{color:rgb(3,3,3)}')
|
|
76
|
+
const label = signal('one')
|
|
77
|
+
const isDark = signal(false)
|
|
78
|
+
let childDisposed = false
|
|
79
|
+
const root = mountInto(
|
|
80
|
+
_rsCollapse(
|
|
81
|
+
'<button><span></span></button>',
|
|
82
|
+
'rsc-c',
|
|
83
|
+
'rsc-cd',
|
|
84
|
+
() => isDark(),
|
|
85
|
+
(el) => {
|
|
86
|
+
const span = el.querySelector('span') as HTMLElement
|
|
87
|
+
const stop = (() => {
|
|
88
|
+
// minimal reactive child without pulling the compiler in
|
|
89
|
+
let raf = 0
|
|
90
|
+
const tick = () => {
|
|
91
|
+
span.textContent = label()
|
|
92
|
+
raf = requestAnimationFrame(tick)
|
|
93
|
+
}
|
|
94
|
+
tick()
|
|
95
|
+
return () => {
|
|
96
|
+
cancelAnimationFrame(raf)
|
|
97
|
+
childDisposed = true
|
|
98
|
+
}
|
|
99
|
+
})()
|
|
100
|
+
return stop
|
|
101
|
+
},
|
|
102
|
+
),
|
|
103
|
+
)
|
|
104
|
+
await flush()
|
|
105
|
+
expect((root.querySelector('span') as HTMLElement).textContent).toBe('one')
|
|
106
|
+
expect((root.querySelector('button') as HTMLElement).className).toBe('rsc-c')
|
|
107
|
+
// dispose via afterEach → child cleanup must fire
|
|
108
|
+
for (const u of cleanup.splice(0)) u()
|
|
109
|
+
expect(childDisposed).toBe(true)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('two instances of the same html share ONE parsed template, with independent reactivity', async () => {
|
|
113
|
+
injectCss('.rsc-s{color:rgb(7,7,7)}.rsc-sd{color:rgb(8,8,8)}')
|
|
114
|
+
const isDark = signal(false)
|
|
115
|
+
const r1 = mountInto(_rsCollapse('<button>dup</button>', 'rsc-s', 'rsc-sd', () => isDark()))
|
|
116
|
+
const r2 = mountInto(_rsCollapse('<button>dup</button>', 'rsc-s', 'rsc-sd', () => isDark()))
|
|
117
|
+
await flush()
|
|
118
|
+
const b1 = r1.querySelector('button') as HTMLElement
|
|
119
|
+
const b2 = r2.querySelector('button') as HTMLElement
|
|
120
|
+
expect(b1).not.toBe(b2) // distinct cloned nodes from the shared template
|
|
121
|
+
expect(b1.className).toBe('rsc-s')
|
|
122
|
+
expect(b2.className).toBe('rsc-s')
|
|
123
|
+
isDark.set(true)
|
|
124
|
+
await flush()
|
|
125
|
+
expect((r1.querySelector('button') as HTMLElement).className).toBe('rsc-sd')
|
|
126
|
+
expect((r2.querySelector('button') as HTMLElement).className).toBe('rsc-sd')
|
|
127
|
+
})
|
|
128
|
+
})
|