Skip to content
Closed
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
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,24 @@

All notable changes to BareDOM will be documented in this file.

## [4.0.0-alpha.1] - 2026-06-04

**Pre-release — published on the npm `alpha` dist-tag (`npm install @vanelsas/baredom@alpha`). The stable `latest` line is unaffected, and `^3.x` ranges never resolve this version.** Patch over `4.0.0-alpha.0` carrying one core fix surfaced while dogfooding the write-side demo. The write-side element surface (`<barebuild-action>`, `<barebuild-invalidate-on>`, the `<barebuild-data>` invalidation substrate) is unchanged from `4.0.0-alpha.0`.

### Fixed

- **`x-select`** — `.value` now reflects the **current user selection** instead of the (never-updated) value *attribute*. Consumers reading `el.value` — `x-form`'s submission collection and any code reading the control directly (e.g. a status-filter dropdown) — got a stale value after the user picked an option. The getter now reads the live inner `<select>`, with a value set before its `<option>` loads (async options) reading back as pending from the attribute until the option arrives. Both pre-existing `.value` semantics tests pass unchanged; two regression tests added.

## [4.0.0-alpha.0] - 2026-06-03

**Pre-release — published on the npm `alpha` dist-tag (`npm install @vanelsas/baredom@alpha`). The stable `latest` line is unaffected, and `^3.x` ranges never resolve this version.** First preview of the BareBuild **write-side** coordination elements, designed-by-use from the Phase-4 demo port. These are **experimental — attribute shapes may change before 4.0.0** (the no-selectors / values-not-places spine will not). See `barebuild/docs/write-side-design-notes.md`.

### Added — write-side ALPHA

- **`<barebuild-action>`** — wraps a submit emitter by containment (a descendant dispatching the configured `submit-event`), JSON-POST/PUT/DELETEs the values, and publishes `.state` + a `barebuild-action-state {name, state}` event per phase transition. Optional `.valuesTransform` property for payload hygiene the action can't itself know (blank-stripping, numeric coercion).
- **`<barebuild-invalidate-on>`** — placed as a child of a source; on a `when-phase`/`when-name` match it dispatches the document-level `barebuild-invalidate {src}` protocol.
- **`<barebuild-data>`** now also listens at document for `barebuild-invalidate` and refetches when `detail.src` matches its own `src` by exact `URL.pathname` — the invalidation substrate (additive; the read-side contract is unchanged).

## [3.3.0] - 2026-05-26

Three new framework adapters ship alongside the existing React and Angular adapters: **Vue 3**, **Svelte 5**, and **SolidJS** are now officially supported. BareDOM users of every major JS framework can now install a typed wrapper package that adds framework-idiomatic props, events, and ref handling on top of the same underlying web components.
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ All adapters are auto-generated from the same Custom Elements Manifest, so addin

## Components

**103 UI web components across 11 categories** — from foundational UI controls to morphing animations, organic effects, and scroll-driven storytelling — plus **3 [BareBuild](./barebuild/docs/read-side.md) orchestration elements** (read-side: router / route / data).
**103 UI web components across 11 categories** — from foundational UI controls to morphing animations, organic effects, and scroll-driven storytelling — plus **5 [BareBuild](./barebuild/docs/read-side.md) orchestration elements** (read-side: router / route / data; write-side alpha: action / invalidate-on).

| Category | Count | Examples |
|----------|------:|----------|
Expand All @@ -118,7 +118,7 @@ All adapters are auto-generated from the same Custom Elements Manifest, so addin
| **Effects** | 12 | Liquid Glass · Confetti · Neural Glow · Metaball Cursor · Organic Shape |
| **Scroll** | 5 | Scroll · Scroll Parallax · Scroll Stack · Scroll Story · Scroll Timeline |
| **Utility** | 2 | i18n · i18n Provider |
| **Orchestration** | 3 | BareBuild Router · Route · Data — read-side; not adapter-wrapped |
| **Orchestration** | 5 | BareBuild Router · Route · Data — read-side; Action · Invalidate-On — write-side (alpha); not adapter-wrapped |

See [**docs/components.md**](./docs/components.md) for the full per-component catalogue with one-line descriptions and links to each component's API documentation.

Expand Down
3 changes: 3 additions & 0 deletions barebuild/demo-app/bb.edn
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@
:task (do (shell "npm install")
(shell "npx shadow-cljs release app")
(apply serve/-main *command-line-args*))}
serve-api {:doc "Independent API-ONLY backend (CORS) on http://localhost:3001 — demonstrates BareBuild is server-agnostic. Serves no static files; pair with a page on another origin via ?api=http://localhost:3001."
:requires ([serve-api])
:task (apply serve-api/-main *command-line-args*)}
e2e {:doc "Run the Playwright read-path smoke. playwright.config boots `bb serve`."
:task (do (shell "npm install")
;; Browsers for a portable / CI run; cached after first install.
Expand Down
75 changes: 75 additions & 0 deletions barebuild/demo-app/e2e/write-paths.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { test, expect } from '@playwright/test';

// Write-path E2E for the ALPHA declarative write side (barebuild-action +
// barebuild-invalidate-on). Drives the real stack and mutates server state, so
// each assertion compares against the live server rather than hardcoded counts.

test('create: barebuild-action POSTs and invalidate-on refetches the board', async ({ page, request }) => {
await page.goto('/tasks');
await page.waitForSelector('#tasks-table x-table-row');
const before = (await (await request.get('/api/tasks')).json()).length;

// Open the modal (in-app: x-button → press → modal.show; call show() directly for a
// deterministic headless run), then fill + submit.
await page.evaluate(() => (document.querySelector('#new-task-modal') as { show(): void }).show());
const title = page.locator('#new-task-form x-form-field[name="title"] input');
await title.waitFor({ state: 'visible' });
await title.fill('E2E declarative task');
await page.click('#new-task-form x-button[type="submit"]');

// No hand-written fetch in write_side: <barebuild-action> POSTed and its child
// <barebuild-invalidate-on> refetched /api/tasks, so the new row appears.
await expect(page.locator('#tasks-table')).toContainText('E2E declarative task');

const after = await (await request.get('/api/tasks')).json();
const created = after.find((t: { title: string }) => t.title === 'E2E declarative task');
expect(created).toBeTruthy();
expect(after.length).toBe(before + 1);
// Payload hygiene restored: write_side installs a `valuesTransform` (without-blanks)
// on #create-action, so an unset status no longer POSTs "" and the server default
// {status:"todo"} applies. (Earlier alpha shipped this as a gap; the transform seam
// on barebuild-action closed it — see write-side-design-notes.md.)
expect(created.status).toBe('todo');
});

test('update: barebuild-action PUTs the edit form (dynamic /api/tasks/:id)', async ({ page, request }) => {
await page.goto('/tasks/2');
await page.waitForSelector('#detail-title');
// The edit action's URL was set to /api/tasks/2 by detail/on-route-change.
await page.fill('#edit-task-form x-form-field[name="assignee"] input', 'Ported Dev');
await page.click('#edit-task-form x-button[type="submit"]');
await expect
.poll(async () => (await (await request.get('/api/tasks/2')).json()).assignee)
.toBe('Ported Dev');
});

test('settings: barebuild-action PUTs and invalidate-on refetches', async ({ page, request }) => {
await page.goto('/settings');
await expect
.poll(() => page.evaluate(() => (document.querySelector('#settings-form [name="theme"]') as { value?: string })?.value))
.toBe('system');
await page.evaluate(() => { (document.querySelector('#settings-form [name="theme"]') as { value: string }).value = 'dark'; });
await page.fill('#settings-form x-form-field[name="page-size"] input', '25');
await page.click('#settings-form x-button[type="submit"]');
await expect
.poll(async () => (await (await request.get('/api/settings')).json()).theme)
.toBe('dark');
// Payload hygiene restored: #settings-action's valuesTransform (with-number) coerces
// page-size, so the merge endpoint stores a NUMBER, not the string the control reports.
const settings = await (await request.get('/api/settings')).json();
expect(settings['page-size']).toBe(25);
expect(typeof settings['page-size']).toBe('number');
});

test('delete (row): hand-wired DELETE + barebuild-invalidate refetches the board', async ({ page, request }) => {
await page.goto('/tasks');
await page.waitForSelector('#tasks-table x-table-row');
const before = await (await request.get('/api/tasks')).json();
const victim = before[before.length - 1].id;
await page.click(`#tasks-table x-button[data-delete-id="${victim}"]`);
// The delegated handler DELETEs, then dispatches barebuild-invalidate {/api/tasks};
// the board self-matches and refetches — the same protocol the declarative flows use.
await expect
.poll(async () => (await (await request.get('/api/tasks')).json()).length)
.toBe(before.length - 1);
});
123 changes: 123 additions & 0 deletions barebuild/demo-app/e2e/xorigin.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { test, expect, request as pwrequest } from '@playwright/test';

// Cross-origin E2E — the point of the second backend: BareBuild's orchestration elements
// are vanilla fetch + CustomEvents that hold no client state, so they work against ANY
// server honouring the contract (server/API-CONTRACT.md). The page is served on :3000
// (serve.clj); selecting the "bb-cors" backend points every API call at the INDEPENDENT
// :3001 server (serve_api.clj, API-only + CORS). The backend is data-driven
// (demo_app.wiring/backends) and chosen via the navbar picker; `?backend=<value>` is the
// deep-link that seeds it. Both web servers are booted by playwright.config.ts.

const API = 'http://localhost:3001';

test('create round-trips to the independent :3001 backend and the board refetches (CORS)', async ({ page }) => {
const api = await pwrequest.newContext();
const before = (await (await api.get(`${API}/api/tasks`)).json()).length;

await page.goto('/tasks?backend=bb-cors');
await page.waitForSelector('#tasks-table x-table-row');

await page.evaluate(() => (document.querySelector('#new-task-modal') as { show(): void }).show());
const title = page.locator('#new-task-form x-form-field[name="title"] input');
await title.waitFor({ state: 'visible' });
await title.fill('Cross-origin task');
await page.click('#new-task-form x-button[type="submit"]');

// <barebuild-action> POSTed cross-origin and the child <barebuild-invalidate-on>
// refetched — so the board (its broker src + the invalidate src both carry the same
// absolute base) shows the new row, all via the elements, no hand-written fetch.
await expect(page.locator('#tasks-table')).toContainText('Cross-origin task');

// It landed on :3001 (the independent server), not the :3000 babashka one.
const after = await (await api.get(`${API}/api/tasks`)).json();
expect(after.find((t: { title: string }) => t.title === 'Cross-origin task')).toBeTruthy();
expect(after.length).toBe(before + 1);
const on3000 = await (await api.get('http://localhost:3000/api/tasks')).json();
expect(on3000.find((t: { title: string }) => t.title === 'Cross-origin task')).toBeFalsy();
});

test('in-app navigation drops ?backend from the URL but the backend stays :3001 (sticky)', async ({ page }) => {
const api = await pwrequest.newContext();
await page.goto('/tasks?backend=bb-cors');
await page.waitForSelector('#tasks-table x-table-row');

// Navigate via the in-app nav link. The router navigates using the anchor's href
// (/settings, no query), so ?backend disappears from the address bar — the original report.
await page.click('a.nav-link[href="/settings"]');
await expect(page).toHaveURL(/\/settings$/);

// The settings form still populated from GET :3001 (read is cross-origin post-nav)…
await expect
.poll(() => page.evaluate(() => (document.querySelector('#settings-form [name="theme"]') as { value?: string })?.value))
.toBeTruthy();
// …and a write still lands on :3001 even though the URL no longer shows ?backend — the
// choice is sticky (sessionStorage), so navigation does not silently revert to :3000.
await page.evaluate(() => { (document.querySelector('#settings-form [name="theme"]') as { value: string }).value = 'light'; });
await page.click('#settings-form x-button[type="submit"]');
await expect
.poll(async () => (await (await api.get(`${API}/api/settings`)).json()).theme)
.toBe('light');
});

test('settings PUT round-trips to :3001 and refetches (CORS preflight path)', async ({ page }) => {
const api = await pwrequest.newContext();

await page.goto('/settings?backend=bb-cors');
// Wait for the form to populate from GET :3001 (proves the read is cross-origin). Don't
// assert a specific initial value: serve_api state persists across the run, so a sibling
// test may have already changed the theme — this test owns only its own set→assert.
await expect
.poll(() => page.evaluate(() => (document.querySelector('#settings-form [name="theme"]') as { value?: string })?.value))
.toBeTruthy();

await page.evaluate(() => { (document.querySelector('#settings-form [name="theme"]') as { value: string }).value = 'dark'; });
await page.click('#settings-form x-button[type="submit"]');

// The PUT is a cross-origin mutation → triggers a CORS preflight serve_api answers.
await expect
.poll(async () => (await (await api.get(`${API}/api/settings`)).json()).theme)
.toBe('dark');
});

test('the navbar backend picker switches to :3001 and the choice survives the reload', async ({ page }) => {
const api = await pwrequest.newContext();
await page.goto('/tasks'); // default: same-origin (:3000)
await page.waitForSelector('#tasks-table x-table-row');
const before = (await (await api.get(`${API}/api/tasks`)).json()).length;

// Pick the CORS backend through the navbar x-select; the handler persists + reloads.
await Promise.all([
page.waitForEvent('load'),
page.evaluate(() => {
document.querySelector('#backend-select')!.dispatchEvent(
new CustomEvent('select-change', { detail: { value: 'bb-cors' }, bubbles: true }));
}).catch(() => {}), // the reload destroys this execution context — expected
]);
await page.waitForSelector('#tasks-table x-table-row');

// After the reload the demo is on :3001 (the choice is sticky). A create lands there.
await page.evaluate(() => (document.querySelector('#new-task-modal') as { show(): void }).show());
const t = page.locator('#new-task-form x-form-field[name="title"] input');
await t.waitFor({ state: 'visible' });
await t.fill('Picker task');
await page.click('#new-task-form x-button[type="submit"]');
await expect(page.locator('#tasks-table')).toContainText('Picker task');

const after = await (await api.get(`${API}/api/tasks`)).json();
expect(after.find((x: { title: string }) => x.title === 'Picker task')).toBeTruthy();
expect(after.length).toBe(before + 1);
});

test('a default load is upgrade-safe: the picker neither reloads nor switches', async ({ page }) => {
// The select-change listener is attached before x-select upgrades. If x-select ever
// echoed select-change on upgrade/programmatic value-set, the handler would spuriously
// select-backend! + reload. Assert exactly ONE load and the picker resting on same-origin.
let loads = 0;
page.on('load', () => { loads += 1; });
await page.goto('/tasks'); // no ?backend → default same-origin
await page.waitForSelector('#tasks-table x-table-row');
await page.waitForTimeout(400); // give any echo-fired reload a chance to happen
const value = await page.evaluate(() => (document.querySelector('#backend-select') as { value?: string })?.value);
expect(value).toBe('same-origin');
expect(loads).toBe(1);
});
2 changes: 1 addition & 1 deletion barebuild/demo-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"build": "shadow-cljs release app"
},
"dependencies": {
"@vanelsas/baredom": "^3.4.0"
"@vanelsas/baredom": "^4.0.0-alpha.1"
},
"devDependencies": {
"@playwright/test": "^1.50.0",
Expand Down
42 changes: 29 additions & 13 deletions barebuild/demo-app/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import { defineConfig } from '@playwright/test';

// Playwright config for the BareBuild Tasks demo read-path smoke.
// Playwright config for the BareBuild Tasks demo E2E.
//
// The webServer boots `bb serve` (build the app + serve public/ + the JSON API on
// a single origin, port 3000) so the spec drives the real stack with no CORS and
// deep-links resolve via the server's SPA fallback. Bundled chromium is used (what
// `bb e2e` provisions via `npx playwright install chromium`, and what CI installs).
// Two web servers boot, demonstrating BareBuild is server-agnostic:
// - `bb serve` — single origin :3000 (app + JSON API via serve.clj). The
// read/write specs drive this with no CORS; SPA deep-links
// resolve via the server's fallback.
// - `bb serve-api 3001` — the INDEPENDENT API-only + CORS server (serve_api.clj) on
// a separate origin. xorigin.spec.ts drives it via ?backend= to
// prove the elements work cross-origin against a different
// backend (see server/API-CONTRACT.md).
// Bundled chromium is used (what `bb e2e` provisions via `npx playwright install
// chromium`, and what CI installs).
export default defineConfig({
testDir: './e2e',
fullyParallel: false,
Expand All @@ -22,12 +28,22 @@ export default defineConfig({
// karma CHROMIUM_FLAGS); a no-op locally.
launchOptions: { args: process.env.CI ? ['--no-sandbox'] : [] },
},
webServer: {
command: 'bb serve',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
// `bb serve` installs deps + does a release build before serving, so give the
// cold path generous headroom.
timeout: 180_000,
},
webServer: [
{
command: 'bb serve',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
// `bb serve` installs deps + does a release build before serving, so give the
// cold path generous headroom.
timeout: 180_000,
},
{
// The independent cross-origin backend for xorigin.spec.ts. API-only, no build,
// so it starts fast; readiness probed on a real endpoint.
command: 'bb serve-api 3001',
url: 'http://localhost:3001/api/tasks',
reuseExistingServer: !process.env.CI,
timeout: 60_000,
},
],
});
Loading
Loading