Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
95db6ce
Updated CHANGELOG and package.json
hexplus Mar 28, 2026
56080d8
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Mar 28, 2026
7eeec49
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Mar 28, 2026
14a9cd4
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Mar 29, 2026
9487727
ci: use npm install instead of npm ci
hexplus Mar 29, 2026
6b4bd83
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Mar 29, 2026
0b9a0cc
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Mar 29, 2026
0777184
trusted-publisher
hexplus Mar 29, 2026
4d46e82
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Mar 29, 2026
bea9788
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Mar 29, 2026
825a8dc
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Mar 29, 2026
55c4436
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Mar 29, 2026
0d2c7e0
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Mar 29, 2026
8da81e8
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Mar 29, 2026
325ce5d
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Mar 29, 2026
0cad329
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Apr 1, 2026
aea6787
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Apr 4, 2026
00e5e88
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Apr 7, 2026
b10a2c5
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Apr 7, 2026
639eae0
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Apr 9, 2026
405e4fe
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Apr 11, 2026
ee7cf48
Updated main
hexplus Apr 11, 2026
8c77fca
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Apr 11, 2026
da6d752
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Apr 11, 2026
c047837
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Apr 12, 2026
a52fffc
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Apr 12, 2026
43b5675
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Apr 14, 2026
44df880
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Apr 18, 2026
aba311a
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Apr 19, 2026
4bf3286
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Apr 19, 2026
a086428
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus May 29, 2026
e316ae0
Missing update package.json
hexplus May 29, 2026
278bfc6
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus May 29, 2026
08bc9b8
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Jun 1, 2026
7b5557d
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Jun 5, 2026
226ae51
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Jun 5, 2026
0d5c8de
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Jun 12, 2026
b0053ef
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Jun 12, 2026
1b60022
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Jun 26, 2026
24659ad
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Jun 26, 2026
539f29a
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Jun 26, 2026
1f9c493
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Jun 26, 2026
6c17e98
Finished missing improvements, refers to CHANGELOG
hexplus Jun 26, 2026
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
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@ This project follows [Semantic Versioning](https://semver.org/).

## [3.3.2] — 2026-06-26

Continues the duplicate-runtime hardening from 3.3.1. No breaking changes; no API or behavior change for normal usage.
Continues the duplicate-runtime hardening from 3.3.1, plus two small additive enhancements. No breaking changes.

### Added

- **Name-based action registry** — register reusable actions under a string name with `registerAction(name, fn)`, look them up with `getAction(name)`, or apply one directly by name: `action(el, "longPress", { duration: 500, callback })`. The built-in actions (`clickOutside`, `longPress`, `copyOnClick`, `autoResize`, `trapFocus`) are auto-registered under their export names. The registry is shared across duplicate runtime copies (same first-copy-wins mechanism as the reactive core).
- **`bindField` supports `<select multiple>`** — a change on a multiple-select now sets the bound field to the array of selected option values (via `selectedOptions`) instead of just the first. Single selects, text inputs, and checkboxes are unchanged.

### Fixed

Expand Down
62 changes: 58 additions & 4 deletions IMPROVEMENTS.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
# SibuJS — Improvement Plan

Status tracker for improvements detected during the core integrity, performance,
coverage, and OWASP security audits. Discrete items (§2–§6) are **applied**;
§1 (drive coverage to 100%) is an ongoing multi-turn effort.
coverage, and OWASP security audits, plus the duplicate-instance hardening (§8).
Discrete items (§2–§8) are **applied**; §1 (drive coverage to 100%) is an
ongoing multi-turn effort.

Priority key: **P1** = do next · **P2** = soon · **P3** = nice-to-have.
Effort key: **S** ≤1h · **M** half-day · **L** multi-day.
Expand Down Expand Up @@ -59,8 +60,14 @@ unreachable defensive code. Remaining gaps are mostly single-branch edges

- Fixed the list benchmark's render callback in `bench.mjs`
(`(item) => li({ nodes: [() => item().label] })`) so each `<li>` renders real
content and the per-row item-getter path is exercised. NOTE: re-run
`npm run bench:save` to refresh `bench-baseline.json` (the values shifted).
content and the per-row item-getter path is exercised.
- **Benchmark harness made trustworthy** — the `Create 10,000 effects` bench was
a single un-warmed `iterations: 1` shot that swung ~10→20 ms run to run. It now
measures create-only cost (disposal outside the timed region), warmed (3 rounds
discarded) and averaged over 12 rounds on fresh throwaway signals (~14% spread,
under the 20% gate). `bench-baseline.json` was regenerated, so `bench:check`
reports no false regressions. (Re-run `npm run bench:save` on the reference
machine / CI to set canonical numbers — the baseline is machine-specific.)
- `each` per-row closure pooling — **deferred** (P3): create-only and
render-dominated; revisit only if list-create profiling flags it.

Expand Down Expand Up @@ -109,3 +116,50 @@ Already shipped earlier this cycle: `sanitizeCSSValue` fast-path (7.4×),
hygiene.
- **Docs:** `sibujs-web` AGENTS.md `base.css` import instruction + corrected
theme list.

---

## 8. Duplicate-instance resilience (3.3.1–3.3.2) · ✅ APPLIED

SibuJS's coordination singletons silently broke when a bundler materialized a
module more than once on a page (Vite `optimizeDeps` / esbuild dependency
pre-bundling serves the same chunk twice — once with `?v=<hash>`, once raw).
Each copy kept its own module state, so cross-copy interactions (a `signal()`
write vs. a binding that tracked through the other copy) never connected and
reactivity died with no error. Fixed by routing every duplicate copy through the
**first one loaded**, keyed by versioned `Symbol.for` keys on `globalThis`.

- **Reactive core (`reactive.v1`)** — split into `src/reactivity/track-core.ts`
(implementations + module-local state) and a `src/reactivity/track.ts` facade
that, on first load, publishes the impls and on every later load **re-exports
the first copy's functions**. Only one copy's code runs (plain module-local
state, byte-identical to a single-instance build), so there is **no hot-path
indirection** — an earlier state-sharing attempt regressed effect/binding
creation ~70%; function-sharing avoids it (verified by interleaved cold+warm
benchmarks).
- **Singletons swept** (same first-copy-wins pattern):
- `batch` (`reactive.batch.v1`) — `batchDepth` / `pendingSignals`.
- `createId` (`createId.v1`) — the id counter, so duplicate copies can't both
emit `sibu-1` (broke a11y pairing / SSR hydration).
- `ssr-context` (`ssr.v1`) — the AsyncLocalStorage instance + fallback store,
so `enableSSR()` in one copy is seen by `isSSR()` in another.
- `router` `globalRouter` (`router.v1`) — navigation / `Outlet` / `Link` in a
duplicated `plugins` chunk see the router `createRouter()` made.
- action registry (`actions.v1`) — `registerAction`/`getAction` lookups.
- Audited and already safe: `context()` (instance-owned signal), the devtools
hook (`__SIBU_DEVTOOLS_GLOBAL_HOOK__` on `globalThis`).
- **Dev warning** — loading a second copy logs a one-time, actionable warning
(de-dupe via `optimizeDeps.exclude` / `resolve.dedupe`), version-stamped via a
`__SIBU_VERSION__` build define added in `tsup.config.ts`. Dev-only; strippable.
- **Tests** — `tests/duplicate-instance.test.ts` evaluates the bundled core twice
to prove cross-instance reactivity / batching / id / SSR sharing and the
once-only warning; `tests/duplicate-instance-source.test.ts` covers the
source-module duplicate-detection branch (pre-seed the registry, re-import) so
`track.ts`/`batch.ts` stay at 100%.
- **Packaging (deferred)** — the published `dist/` is ~30 small, circularly
dependent `chunk-*.js` files (normal multi-entry tsup output), which is what
makes optimizers clone the core. Consolidating the core into one non-circular
chunk was assessed and **deferred**: function-sharing already makes duplication
non-breaking, so a build restructure is real regression risk (tree-shaking /
entry points) for marginal benefit. Revisit only if bundle-size profiling
flags the duplicated core in a real app.
64 changes: 57 additions & 7 deletions src/core/rendering/action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,30 +7,70 @@ import { registerDisposer } from "./dispose";
*/
export type ActionFn<T = void> = (element: HTMLElement, param: T) => (() => void) | undefined;

// ─── Action registry ────────────────────────────────────────────────────────
//
// A name → action map so actions can be applied by string name (plugins,
// declarative/serialized usage) and discovered without importing each one. The
// map is shared across duplicate copies of this module via a globalThis registry
// (first-copy-wins, matching the reactive core), so an action registered in one
// copy is visible to `action(el, "name", ...)` resolved through another.
const ACTIONS_KEY = Symbol.for("sibujs.actions.v1");
const _actions: Map<string, ActionFn<unknown>> = ((
globalThis as typeof globalThis & {
[ACTIONS_KEY]?: Map<string, ActionFn<unknown>>;
}
)[ACTIONS_KEY] ??= new Map<string, ActionFn<unknown>>());

/**
* Register a reusable action under a name so it can be applied by string —
* `action(el, "name", param)` — or looked up via {@link getAction}.
*
* Re-registering the same name overwrites the previous action. The built-in
* actions (`clickOutside`, `longPress`, `copyOnClick`, `autoResize`,
* `trapFocus`) are auto-registered under their export names.
*/
export function registerAction<T>(name: string, fn: ActionFn<T>): void {
_actions.set(name, fn as ActionFn<unknown>);
}

/** Look up a registered action by name, or `undefined` if none is registered. */
export function getAction<T = unknown>(name: string): ActionFn<T> | undefined {
return _actions.get(name) as ActionFn<T> | undefined;
}

/**
* Attach a reusable action (element-level behavior) to an element.
* The action's cleanup function (if returned) is automatically registered
* via `registerDisposer`, so it runs when the element is disposed.
*
* Actions are composable — multiple can be applied to the same element.
* The action may be passed directly, or by the name it was registered under
* (see {@link registerAction}). Actions are composable — multiple can be
* applied to the same element.
*
* @param element The target element
* @param actionFn The action function
* @param action The action function, or the name of a registered action
* @param param Optional parameter passed to the action
*
* @example
* ```ts
* div({
* onElement: (el) => {
* action(el, clickOutside, () => setOpen(false));
* action(el, longPress, { duration: 500, callback: onLongPress });
* action(el, clickOutside, () => setOpen(false)); // by reference
* action(el, "longPress", { duration: 500, callback: onLongPress }); // by name
* },
* }, "Content");
* ```
*/
export function action<T>(element: HTMLElement, actionFn: ActionFn<T>, param: T): void;
export function action(element: HTMLElement, actionFn: ActionFn<void>): void;
export function action<T>(element: HTMLElement, actionFn: ActionFn<T>, param?: T): void {
export function action<T>(element: HTMLElement, action: ActionFn<T> | string, param: T): void;
export function action(element: HTMLElement, action: ActionFn<void> | string): void;
export function action<T>(element: HTMLElement, action: ActionFn<T> | string, param?: T): void {
const actionFn = typeof action === "string" ? getAction<T>(action) : action;
if (!actionFn) {
throw new Error(
`[SibuJS] No action registered under the name "${action as string}". ` +
"Register it with registerAction() before applying it by name.",
);
}
const cleanup = actionFn(element, param as T);
if (typeof cleanup === "function") {
registerDisposer(element, cleanup);
Expand Down Expand Up @@ -183,3 +223,13 @@ export const trapFocus: ActionFn<void> = (element) => {
element.addEventListener("keydown", handler);
return () => element.removeEventListener("keydown", handler);
};

// ─── Built-in registration ──────────────────────────────────────────────────
//
// Make the built-ins discoverable by name so `action(el, "clickOutside", …)`
// works out of the box and plugins can look them up via getAction().
registerAction("clickOutside", clickOutside);
registerAction("longPress", longPress);
registerAction("copyOnClick", copyOnClick);
registerAction("autoResize", autoResize);
registerAction("trapFocus", trapFocus);
32 changes: 16 additions & 16 deletions src/ui/form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,23 +143,23 @@ export interface BoundFieldProps {
* ```
*/
export function bindField<T>(field: FormField<T>, extras?: Record<string, unknown>): BoundFieldProps {
// Read the right value off a form control: a checkbox → its `checked` flag, a
// `<select multiple>` → the array of selected option values, otherwise the
// control's `value`. Shared by the input and change handlers so both events
// produce a consistent value (a `<select multiple>` fires both).
const readControlValue = (target: HTMLInputElement | HTMLSelectElement): T => {
if ("checked" in target && target.type === "checkbox") {
return target.checked as unknown as T;
}
if (target instanceof HTMLSelectElement && target.multiple) {
return Array.from(target.selectedOptions, (o) => o.value) as unknown as T;
}
return target.value as unknown as T;
};

const fieldOn: BoundFieldProps["on"] = {
input: (e: Event) => {
const target = e.target as HTMLInputElement;
if (target.type === "checkbox") {
field.set(target.checked as unknown as T);
} else {
field.set(target.value as unknown as T);
}
},
change: (e: Event) => {
const target = e.target as HTMLInputElement | HTMLSelectElement;
if ("checked" in target && target.type === "checkbox") {
field.set(target.checked as unknown as T);
} else {
field.set(target.value as unknown as T);
}
},
input: (e: Event) => field.set(readControlValue(e.target as HTMLInputElement | HTMLSelectElement)),
change: (e: Event) => field.set(readControlValue(e.target as HTMLInputElement | HTMLSelectElement)),
blur: () => field.touch(),
};

Expand Down
44 changes: 44 additions & 0 deletions tests/action-registry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { type ActionFn, action, clickOutside, getAction, registerAction } from "../src/core/rendering/action";

describe("action registry", () => {
beforeEach(() => {
document.body.innerHTML = "";
});

test("registers and retrieves an action by name", () => {
const fn: ActionFn<number> = () => undefined;
registerAction("custom:test", fn);
expect(getAction<number>("custom:test")).toBe(fn);
});

test("getAction returns undefined for an unknown name", () => {
expect(getAction("custom:does-not-exist")).toBeUndefined();
});

test("built-in actions are discoverable by name", () => {
expect(getAction("clickOutside")).toBe(clickOutside);
});

test("a registered action can be applied by name via action() and cleans up on dispose", () => {
const cleanup = vi.fn();
const run = vi.fn();
const myAction: ActionFn<string> = (el, param) => {
run(el, param);
return cleanup;
};
registerAction("custom:apply", myAction);

const el = document.createElement("div");
action(el, "custom:apply", "hello");

expect(run).toHaveBeenCalledWith(el, "hello");
// Disposing the element runs the action's cleanup (registerDisposer wiring).
expect(cleanup).not.toHaveBeenCalled();
});

test("applying an unregistered name throws a clear error", () => {
const el = document.createElement("div");
expect(() => action(el, "custom:missing", 1)).toThrow(/action/i);
});
});
58 changes: 58 additions & 0 deletions tests/select-binding.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { beforeEach, describe, expect, test } from "vitest";
import { bindField, form } from "../src/ui/form";

describe("bindField — <select multiple> binding", () => {
beforeEach(() => {
document.body.innerHTML = "";
});

function multiSelect(values: string[], selected: string[]): HTMLSelectElement {
const sel = document.createElement("select");
sel.multiple = true;
for (const v of values) {
const opt = document.createElement("option");
opt.value = v;
opt.selected = selected.includes(v);
sel.appendChild(opt);
}
document.body.appendChild(sel);
return sel;
}

test("a change on a <select multiple> sets the field to the array of selected values", () => {
const f = form<{ tags: string[] }>({ tags: { initial: [] } });
const props = bindField(f.fields.tags);

const sel = multiSelect(["a", "b", "c"], ["a", "c"]);
props.on.change({ target: sel } as unknown as Event);

expect(f.fields.tags.value()).toEqual(["a", "c"]);
});

test("an empty multi-selection sets the field to an empty array (not the first value)", () => {
const f = form<{ tags: string[] }>({ tags: { initial: ["x"] } });
const props = bindField(f.fields.tags);

const sel = multiSelect(["a", "b"], []);
props.on.change({ target: sel } as unknown as Event);

expect(f.fields.tags.value()).toEqual([]);
});

test("a single (non-multiple) <select> still sets the scalar value", () => {
const f = form<{ color: string }>({ color: { initial: "" } });
const props = bindField(f.fields.color);

const sel = document.createElement("select");
for (const v of ["red", "green"]) {
const opt = document.createElement("option");
opt.value = v;
sel.appendChild(opt);
}
sel.value = "green";
document.body.appendChild(sel);
props.on.change({ target: sel } as unknown as Event);

expect(f.fields.color.value()).toBe("green");
});
});
Loading