Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 57 additions & 90 deletions packages/frontend/navi/dist/jsenv_navi.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { installImportMetaCssBuild } from "./jsenv_navi_side_effects.js";
import { isValidElement, createContext, h, options, toChildArray, render, cloneElement } from "preact";
import { isValidElement, createContext, h, toChildArray, render, cloneElement } from "preact";
import { useErrorBoundary, useLayoutEffect, useEffect, useContext, useMemo, useRef, useState, useCallback, useImperativeHandle, useId } from "preact/hooks";
import { jsxs, jsx, Fragment } from "preact/jsx-runtime";
import { signal, effect, computed, batch, useSignal } from "@preact/signals";
Expand Down Expand Up @@ -8041,72 +8041,42 @@ const updateStyle = (element, style, preventInitialTransition) => {
styleKeySetWeakMap.set(element, styleKeySet);
};

// Implementation notes:
//
// options.__r fires before each component render — we capture the current
// component instance (vnode.__c) so useEarlyDOMEffect can register itself.
//
// options.__c (commitRoot) fires after refs are assigned and before any
// useLayoutEffect runs. We flush all pending effects there.
// The DOM node is read from component.__v.__e (vnode → root DOM node),
// which Preact sets during diffing, before options.__c fires.
//
// stateMap (WeakMap) stores { cleanup, deps } per component instance.
// It's auto-GC'd when a component is destroyed; options.unmount also
// deletes entries eagerly to release cleanup functions sooner.
//
// pendingMap (Map) holds effects registered during the current render pass.
// It is always fully cleared in options.__c — bounded to one commit, no leak.

/**
* Like useLayoutEffect, but runs before any layout effect in the commit —
* including those of descendant components.
*
* Use this when a parent needs to mutate the DOM (e.g. apply styles) so that
* children can read those mutations in their own useLayoutEffect.
*
* The DOM node of the component is passed as the first argument to fn.
* The effect is skipped if no DOM node is found (e.g. on a fragment root).
*
* Supports deps and cleanup return, same as useLayoutEffect.
* Keeps a DOM element in sync with `syncElement(el)` whenever deps change.
* - If element is already mounted: runs syncElement immediately during render.
* - If not yet mounted: runs syncElement in the ref callback when element arrives.
* - Calls cleanup (if returned by syncElement) before each re-run and on unmount.
*
* @param {function|object|null} externalRef - Optional ref to forward to
* @param {function} syncElement - Called with the DOM element when deps change
* @param {Array} deps - syncElement is re-called only when deps change
*/
const useEarlyDOMEffect = (fn, deps, { needDOMNode = true } = {}) => {
const component = _currentComponent;
if (component) {
pendingMap.set(component, { fn, deps, needDOMNode });
}
};

// Populated during render, consumed + cleared in options.__c each commit.
const pendingMap = new Map(); // component → { fn, deps, ref }

// Persists across commits. WeakMap → no leak when component is destroyed.
const stateMap = new WeakMap(); // component → { cleanup, deps }
const useElementRefEffect = (externalRef, syncElement, deps) => {
const cleanupRef = useRef(null);
const elRef = useRef(null);
const prevDepsRef = useRef(undefined);
const refCallbackRef = useRef(null);

let _currentComponent = null;
const _prevBeforeRender = options.__r;
options.__r = (vnode) => {
_currentComponent = vnode.__c;
if (_prevBeforeRender) {
_prevBeforeRender(vnode);
}
};

const _prevCommit = options.__c;
options.__c = (root, commitQueue) => {
for (const [component, { fn, deps, needDOMNode }] of pendingMap) {
// component.__v is the component's vnode; __e is its root DOM node.
// Both are set during diff, before options.__c fires.
const element = component.__v && component.__v.__e;
if (needDOMNode && !element) {
continue;
const runSync = (el) => {
if (cleanupRef.current) {
cleanupRef.current();
cleanupRef.current = null;
}
const prev = stateMap.get(component);
const prevDeps = prev ? prev.deps : undefined;
prevDepsRef.current = deps;
const cleanup = syncElement(el);
if (typeof cleanup === "function") {
cleanupRef.current = cleanup;
}
};

// If element already mounted, check deps and sync during render.
if (elRef.current) {
const prevDeps = prevDepsRef.current;
let depsChanged;
if (!prevDeps || !deps || prevDeps.length !== deps.length) {
if (!prevDeps || prevDeps.length !== deps.length) {
depsChanged = true;
} else {
depsChanged = false;
for (let i = 0; i < deps.length; i++) {
if (!Object.is(deps[i], prevDeps[i])) {
depsChanged = true;
Expand All @@ -8115,35 +8085,35 @@ options.__c = (root, commitQueue) => {
}
}
if (depsChanged) {
if (prev && prev.cleanup) {
prev.cleanup();
}
const result = fn(element);
const cleanup = typeof result === "function" ? result : undefined;
stateMap.set(component, { cleanup, deps });
runSync(elRef.current);
}
}
pendingMap.clear();
if (_prevCommit) {
_prevCommit(root, commitQueue);
}
};

const _prevUnmount = options.unmount;
options.unmount = (vnode) => {
const component = vnode.__c;
if (component) {
const state = stateMap.get(component);
if (state && state.cleanup) {
state.cleanup();
}
// stateMap is a WeakMap so the entry is GC'd automatically,
// but deleting explicitly releases the cleanup fn sooner.
stateMap.delete(component);
}
if (_prevUnmount) {
_prevUnmount(vnode);
if (!refCallbackRef.current) {
refCallbackRef.current = (el) => {
elRef.current = el;
if (externalRef) {
if (typeof externalRef === "function") {
externalRef(el);
} else {
externalRef.current = el;
}
}
if (el) {
runSync(el);
} else {
if (cleanupRef.current) {
cleanupRef.current();
cleanupRef.current = null;
}
prevDepsRef.current = undefined;
}
};
}

const refCallback = refCallbackRef.current;
refCallback.current = elRef.current;
return refCallback;
};

installImportMetaCssBuild(import.meta);/**
Expand Down Expand Up @@ -8323,8 +8293,7 @@ const Box = props => {
separator,
...rest
} = props;
const defaultRef = useRef();
const ref = props.ref || defaultRef;
let ref;
const TagName = as;
const defaultDisplay = getDefaultDisplay(TagName);
// Read the parent flow early so we can use it when display="inherit" is requested.
Expand Down Expand Up @@ -8677,9 +8646,7 @@ const Box = props => {
styleDeps.push(...pseudoClasses);
}
}
// TODO: just use ref function, it will be called same time as early dom effect + give the dom node + be standard
// we need to implent our styleDeps tracking but that's likely very easy
useEarlyDOMEffect(boxEl => {
ref = useElementRefEffect(props.ref, boxEl => {
const pseudoStateEl = pseudoStateSelector ? boxEl.querySelector(pseudoStateSelector) : boxEl;
const visualEl = visualSelector ? boxEl.querySelector(visualSelector) : null;
return initPseudoStyles(pseudoStateEl, {
Expand Down
15 changes: 7 additions & 8 deletions packages/frontend/navi/dist/jsenv_navi.js.map

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion packages/frontend/navi/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@jsenv/navi",
"version": "0.26.19",
"version": "0.26.20",
"type": "module",
"description": "Library of components including navigation to create frontend applications",
"repository": {
Expand Down
61 changes: 31 additions & 30 deletions packages/frontend/navi/src/box/box.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@

import { normalizeStyles } from "@jsenv/dom";
import { toChildArray } from "preact";
import { useContext, useRef } from "preact/hooks";
import { useContext } from "preact/hooks";

import { withPropsClassName } from "../utils/with_props_class_name.js";
import { BoxFlowContext } from "./box_flow_context.jsx";
Expand All @@ -70,7 +70,7 @@ import {
PSEUDO_NAMED_STYLES_DEFAULT,
PSEUDO_STATE_DEFAULT,
} from "./pseudo_styles.js";
import { useEarlyDOMEffect } from "./use_early_dom_effect.js";
import { useElementRefEffect } from "./use_element_ref.js";

import.meta.css = /* css */ `
@layer navi {
Expand Down Expand Up @@ -200,8 +200,7 @@ export const Box = (props) => {
separator,
...rest
} = props;
const defaultRef = useRef();
const ref = props.ref || defaultRef;
let ref;
const TagName = as;

const defaultDisplay = getDefaultDisplay(TagName);
Expand Down Expand Up @@ -620,32 +619,34 @@ export const Box = (props) => {
styleDeps.push(...pseudoClasses);
}
}
// TODO: just use ref function, it will be called same time as early dom effect + give the dom node + be standard
// we need to implent our styleDeps tracking but that's likely very easy
useEarlyDOMEffect((boxEl) => {
const pseudoStateEl = pseudoStateSelector
? boxEl.querySelector(pseudoStateSelector)
: boxEl;
const visualEl = visualSelector
? boxEl.querySelector(visualSelector)
: null;
return initPseudoStyles(pseudoStateEl, {
pseudoClasses: innerPseudoClasses,
pseudoState: innerPseudoState,
effect: (state) => {
applyStyle(
boxEl,
boxStyles,
state,
boxPseudoNamedStyles,
preventInitialTransition,
);
},
elementToImpact: boxEl,
elementListeningPseudoState:
visualEl === pseudoStateEl ? null : visualEl,
});
}, styleDeps);
ref = useElementRefEffect(
props.ref,
(boxEl) => {
const pseudoStateEl = pseudoStateSelector
? boxEl.querySelector(pseudoStateSelector)
: boxEl;
const visualEl = visualSelector
? boxEl.querySelector(visualSelector)
: null;
return initPseudoStyles(pseudoStateEl, {
pseudoClasses: innerPseudoClasses,
pseudoState: innerPseudoState,
effect: (state) => {
applyStyle(
boxEl,
boxStyles,
state,
boxPseudoNamedStyles,
preventInitialTransition,
);
},
elementToImpact: boxEl,
elementListeningPseudoState:
visualEl === pseudoStateEl ? null : visualEl,
});
},
styleDeps,
);
}

// When hasChildFunction is used it means
Expand Down
48 changes: 40 additions & 8 deletions packages/frontend/navi/src/box/demos/box_style_timing_demo.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
<div id="app"></div>
<script type="module" jsenv-type="module/jsx">
import { render } from "preact";
import { signal } from "@preact/signals";
import { useState, useLayoutEffect, useRef } from "preact/hooks";
import { Box } from "@jsenv/navi";

Expand Down Expand Up @@ -67,6 +68,37 @@

const COLORS = ["#ff6b6b", "#4dabf7", "#69db7c", "#ffd43b", "#da77f2"];

const nativeRefCallCount = signal(0);
const nativeRef = () => {
nativeRefCallCount.value++;
};

const boxRefCallCount = signal(0);
const boxRef = () => {
boxRefCallCount.value++;
};

const RefCallCounter = ({ count }) => {
return (
<div
style={{
fontFamily: "monospace",
fontSize: "13px",
padding: "6px 10px",
background: "#1e1e2e",
color: "#cdd6f4",
borderRadius: "6px",
marginBottom: "8px",
}}
>
ref callback calls:{" "}
<strong style={{ color: "#a6e3a1", fontSize: "16px" }}>
{count}
</strong>
</div>
);
};

const Demo = () => {
const [colorIndex, setColorIndex] = useState(0);
const bg = COLORS[colorIndex];
Expand Down Expand Up @@ -119,12 +151,12 @@ <h2 style={{ margin: 0 }}>Box style timing test</h2>
</div>

<div style={{ display: "flex", gap: "24px" }}>
{/* Case 1: native Preact style prop — style is in the DOM before any useLayoutEffect */}
{/* Case 1: native Preact style prop */}
<div style={{ flex: 1 }}>
<h3 style={{ marginTop: 0 }}>
Native Preact <code>style</code> prop
</h3>
<h3 style={{ marginTop: 0 }}>{"<div ref style>"}</h3>
<RefCallCounter count={nativeRefCallCount} />
<div
ref={nativeRef}
style={{
backgroundColor: bg,
padding: "16px",
Expand All @@ -135,12 +167,12 @@ <h3 style={{ marginTop: 0 }}>
</div>
</div>

{/* Case 2: Box with its own style system — timing under test */}
{/* Case 2: Box with its own style system */}
<div style={{ flex: 1 }}>
<h3 style={{ marginTop: 0 }}>
<code>{"<Box background>"}</code>
</h3>
<h3 style={{ marginTop: 0 }}>{"<Box ref background>"}</h3>
<RefCallCounter count={boxRefCallCount} />
<Box
ref={boxRef}
background={bg}
style={{ padding: "16px", borderRadius: "6px" }}
>
Expand Down
Loading
Loading