diff --git a/CHANGELOG.md b/CHANGELOG.md index b36da93..d43a259 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,141 @@ This project follows [Semantic Versioning](https://semver.org/). --- --- +## [4.4.0] — 2026-09-07 + +### Added — `cdn.full.global.js`, patterns for no-build pages + +A ` + +``` + +Load **one or the other**, never both. It is a separate file rather than a +merge so the default bundle keeps its byte budget: patterns costs ~13% gzip, +and most pages never call any of it. `cdn.full.dev.global.js` is the +development counterpart, and both are reachable as `sibujs/cdn-full` and +`sibujs/cdn-full-dev`. + +A bundled app needs neither file — import from `"sibujs"` and let your +bundler define `__SIBU_DEV__` per build (the Vite and webpack plugins in +`sibujs/build` do this for you). The two CDN files exist because a ` +// +// +// WHY THIS IS A SEPARATE FILE. A diff --git a/package-lock.json b/package-lock.json index 4f1516f..cafed5e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sibujs", - "version": "4.3.0", + "version": "4.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sibujs", - "version": "4.3.0", + "version": "4.4.0", "license": "MIT", "devDependencies": { "@biomejs/biome": "2.4.7", diff --git a/package.json b/package.json index 9948daf..1d24cf6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sibujs", - "version": "4.3.0", + "version": "4.4.0", "description": "A lightweight, function-based frontend framework that combines the best of React, Svelte, and Vue — with zero VDOM and maximum simplicity. Designed for developers who want fine-grained reactivity and full control without compilation or magic.", "keywords": [ "frontend", @@ -133,6 +133,12 @@ }, "./cdn-dev": { "default": "./dist/cdn.dev.global.js" + }, + "./cdn-full": { + "default": "./dist/cdn.full.global.js" + }, + "./cdn-full-dev": { + "default": "./dist/cdn.full.dev.global.js" } }, "publishConfig": { diff --git a/src/patterns/contracts.ts b/src/patterns/contracts.ts index 6acb732..5198d50 100644 --- a/src/patterns/contracts.ts +++ b/src/patterns/contracts.ts @@ -1,9 +1,31 @@ /** * Runtime prop validation and strict typing contracts for SibuJS. * Provides runtime type checking for component props in development mode. + * + * Both diagnostics here gate on `DEV`, the same foldable flag the rest of the + * framework uses, so a `__SIBU_DEV__: false` define removes them and their + * message text entirely. They used to test `process.env.NODE_ENV` directly, + * which is not a `define` target and does not exist in a browser at all — so + * every browser build treated itself as development: `validateProps` ran and + * warned, `assertType` threw, and the strings shipped. That went unnoticed + * while these functions were reachable only through a bundler; putting + * `patterns` on the CDN made them a production artifact. */ -declare var process: { env?: { NODE_ENV?: string } } | undefined; +import { DEV, devWarn } from "../core/dev"; + +declare const __SIBU_DEV__: boolean | undefined; + +// The gate is written INLINE at both call sites below, not hoisted into a +// const, and it leads with the BARE `__SIBU_DEV__`. +// +// Only a bare identifier is a `define` target, and a define is substituted +// early — before dead-code elimination runs. The imported `DEV` const is +// inlined LATE: esbuild folded it to `!1` and then left `if (!1) { … }` +// standing, so the whole assertion body and its message shipped in the +// production CDN bundle behind a condition that could never be true. `devWarn` +// has used this shape for the same reason; `tests/dist-artifacts.test.ts` now +// asserts the result on the published bytes. // ─── Type Validators ──────────────────────────────────────────────────────── @@ -90,48 +112,85 @@ export type PropSchema = { // ─── validateProps ────────────────────────────────────────────────────────── /** - * Validate props against a schema. Returns validated props with defaults applied. - * In production mode (process.env.NODE_ENV === 'production'), validation is skipped - * and only defaults are applied for performance. + * Validate props against a schema, returning the props with defaults applied. + * + * Defaults are production behaviour; the checking is development-only and is + * compiled out of production builds. The two are not interchangeable when the + * schema's callbacks touch outside state: a validator that runs in development + * and not in production can leave a later property's default factory reading + * different state. Keep defaults and validators free of side effects if the + * two modes must agree exactly. + * + * @param props - The props to validate. Not mutated. + * @param schema - Per-property definitions, in either the shorthand + * (a bare {@link Validator}) or object form. + * @returns A new object: the props, plus any defaults that applied. */ export function validateProps(props: Partial, schema: PropSchema): Props { const result = { ...props } as Record; - const errors: string[] = []; - const isDev = typeof process === "undefined" || process?.env?.NODE_ENV !== "production"; - - for (const [key, def] of Object.entries(schema)) { - const propDef: PropDef = typeof def === "function" ? { type: def as Validator } : (def as PropDef); - // Apply defaults - if (result[key] == null && propDef.default !== undefined) { - result[key] = typeof propDef.default === "function" ? (propDef.default as () => unknown)() : propDef.default; - } + // TWO WHOLE LOOPS, one per mode, with no shared helper between them. + // + // Ordering is observable: defaults and validators are user callbacks that may + // read or write outside state, so each property is finished — normalize, + // default, validate — before the next begins. Running all defaults and then + // all validators would reorder those calls. + // + // Everything development-only lives inside the branch, INCLUDING the `errors` + // array and the normalization. Both leaked out of production once: the array + // by being hoisted above a shared loop, the normalization by living in a + // helper both branches called. A shared helper is also where an allocation + // hides from a test that reads `validateProps.toString()`, which is why the + // duplication below is deliberate. + if (typeof __SIBU_DEV__ !== "undefined" ? __SIBU_DEV__ : DEV) { + const errors: string[] = []; + + for (const [key, def] of Object.entries(schema)) { + const propDef: PropDef = typeof def === "function" ? { type: def as Validator } : (def as PropDef); + + if (result[key] == null && propDef.default !== undefined) { + result[key] = typeof propDef.default === "function" ? (propDef.default as () => unknown)() : propDef.default; + } - if (!isDev) continue; // Skip validation in production + if (propDef.required && result[key] == null) { + errors.push(`Prop '${key}' is required`); + continue; + } - // Check required - if (propDef.required && result[key] == null) { - errors.push(`Prop '${key}' is required`); - continue; - } + if (result[key] == null) continue; - if (result[key] == null) continue; + if (propDef.type) { + const typeResult = propDef.type(result[key], key); + if (typeResult !== true) errors.push(typeResult); + } - // Type validation - if (propDef.type) { - const typeResult = propDef.type(result[key], key); - if (typeResult !== true) errors.push(typeResult); + if (propDef.validator) { + const validResult = propDef.validator(result[key], key); + if (validResult !== true) errors.push(validResult); + } } - // Custom validator - if (propDef.validator) { - const validResult = propDef.validator(result[key], key); - if (validResult !== true) errors.push(validResult); + if (errors.length > 0) { + devWarn(`Prop validation errors:\n${errors.map((e) => ` - ${e}`).join("\n")}`); } + + return result as Props; } - if (errors.length > 0 && isDev) { - console.warn(`[SibuJS] Prop validation errors:\n${errors.map((e) => ` - ${e}`).join("\n")}`); + // Production: defaults only, in the same order. It still allocates the + // returned copy and whatever `Object.entries` builds — both are required to + // do the job. What is gone is every allocation that existed only to support + // validation: the `errors` array and the normalized `{ type: def }`. + for (const [key, def] of Object.entries(schema)) { + // The shorthand form IS a bare validator, so it carries no default and + // there is nothing to do. Normalizing it to `{ type: def }` would allocate + // an object per entry for validation that does not run here. + if (typeof def === "function") continue; + + const fallback = (def as PropDef).default; + if (result[key] == null && fallback !== undefined) { + result[key] = typeof fallback === "function" ? (fallback as () => unknown)() : fallback; + } } return result as Props; @@ -158,13 +217,19 @@ export function defineStrictComponent(config: { /** * Assert that a value satisfies a contract at runtime. - * No-op in production builds. + * + * No-op in production builds — genuinely, now. The previous guard returned early + * only when `process` existed, so in a browser it fell through and threw. */ export function assertType(value: unknown, validator: Validator, label?: string): asserts value is T { - if (typeof process !== "undefined" && process?.env?.NODE_ENV === "production") return; - const result = validator(value as T, label || "value"); - if (result !== true) { - throw new TypeError(`[SibuJS Contract] ${result}`); + // Wrapped rather than an early `return` for the same reason as the loop + // above: code after an unconditional return is unreachable, not deleted, so + // the assertion body and its message shipped in production. + if (typeof __SIBU_DEV__ !== "undefined" ? __SIBU_DEV__ : DEV) { + const result = validator(value as T, label || "value"); + if (result !== true) { + throw new TypeError(`[SibuJS Contract] ${result}`); + } } } diff --git a/tests-browser/cdn-full.spec.ts b/tests-browser/cdn-full.spec.ts new file mode 100644 index 0000000..660e151 --- /dev/null +++ b/tests-browser/cdn-full.spec.ts @@ -0,0 +1,135 @@ +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { expect, test } from "@playwright/test"; + +// --------------------------------------------------------------------------- +// Real-browser coverage for the published CDN artifacts. +// +// These run the built IIFEs the way a no-build page does — a '; + + it("finds a live classic script", () => { + const live = `${island}`; + expect(classicScriptSources(live)).toContain(FULL_CDN_SRC); + expect(scriptTagIndex(live, FULL_CDN_SRC)).toBeGreaterThan(-1); + }); + + it("ignores a script commented out across several lines", () => { + const multiline = ` + + ${island} +`; + expect(classicScriptSources(multiline)).not.toContain(FULL_CDN_SRC); + expect(scriptTagIndex(multiline, FULL_CDN_SRC)).toBe(-1); + }); + + it("ignores a script commented out on one line", () => { + const singleLine = ` + + ${island} +`; + expect(classicScriptSources(singleLine)).not.toContain(FULL_CDN_SRC); + expect(scriptTagIndex(singleLine, FULL_CDN_SRC)).toBe(-1); + }); + + it("ignores a filename mentioned in ordinary comment prose", () => { + const prose = `${island}`; + expect(classicScriptSources(prose)).toEqual([]); + expect(scriptTagIndex(prose, FULL_CDN_SRC)).toBe(-1); + }); + + it("excludes module scripts from the classic list", () => { + expect(classicScriptSources(island)).toEqual([]); + }); + + it("detects a live core-only tag, which the page assertion then rejects", () => { + const core = `${island}`; + expect(classicScriptSources(core)).toEqual([CORE_CDN_SRC]); + }); + + it("orders a live runtime tag before the island, and a commented one not at all", () => { + const live = `${island}`; + expect(scriptTagIndex(live, FULL_CDN_SRC)).toBeLessThan(scriptTagIndex(live, "./chess-island.js")); + + const commented = `${island}`; + expect(scriptTagIndex(commented, FULL_CDN_SRC)).toBe(-1); + }); +}); + describe.skipIf(!distBuilt || !vendorBuilt)("chess example — production output is servable", () => { it("serves the directory URL as the example page", async () => { // The classic deployment failure: `/examples/chess/` resolving to a @@ -125,11 +234,56 @@ describe.skipIf(!distBuilt || !vendorBuilt)("chess example — production output } expect(failures).toEqual([]); - // The graph really was walked: the island, the vendored engine and the - // package's own entry points. - expect(seen.size).toBeGreaterThan(3); + // The graph really was walked: the island and the vendored engine. + expect(seen.size).toBeGreaterThan(1); expect([...seen].some((u) => u.endsWith("/vendor/chess.js"))).toBe(true); - expect([...seen].some((u) => u.includes("/dist/index.js"))).toBe(true); + + // And the framework is NOT in it. The example takes SibuJS from the + //