diff --git a/packages/v4/DESIGN.md b/packages/v4/DESIGN.md index 9e8029638..3cb7b983f 100644 --- a/packages/v4/DESIGN.md +++ b/packages/v4/DESIGN.md @@ -614,6 +614,41 @@ The reactive container is called `Signal` — the name the ecosystem settled on This is the property @studiometa/ui's `DataChannel` gets from alien-signals today, and the reason it can be dropped rather than depended on — its `publish()` always builds a fresh frame object, so the `===` bail-out never fires there and what it actually relies on is _one delivery per subscriber per settle, carrying the latest value_. Settling stays in the same task on purpose: `DataBind` echoes form-control input, and a microtask hop would be a visible change of behaviour. The price is that a subscriber writing unconditionally on every delivery live-locks the loop rather than overflowing the stack, which is the same trade every synchronous reactive graph makes. +#### A set of peers — `createGroup()` + +v3's `withGroup` gave every instance a `$group: Set` of its peers, keyed by a group name and optionally scoped by a resolver. §5's `provideRootContext()` section states why it is not ported: the set had no value cell. What that section did not settle is where the _membership_ itself lives, and the answer is not a second registry. **A group is a membership published as one `Signal`, created by whoever coordinates it and reached the way every other shared value is reached — as a context.** + +```js +// The coordinator owns the membership and hands out the ways in. +const peers = createGroup(); +api = this.$provide(DisclosureGroupContext, { + members: peers.members, // the members to read, in document order + join: (peer) => peers.join(peer), // returns the leave function + open: (peer) => this.open(peer), // the invariant stays here +}); +``` + +```js +// A member joins the nearest group, whenever that group appears. +mounted() { + return subscribeContext(this.$el, DisclosureGroupContext, (group) => { + this.group = group; + const leave = group.join(this); + return () => { leave(); this.group = undefined; }; + }); +} +``` + +`join()` returning its own `leave` is the whole ergonomic point: the shape of `subscribeContext()`'s answer/teardown contract already _is_ the shape of joining and leaving a group, so a member that migrates to a nearer group leaves the old one before it joins the new one, with no `__connect`/`__disconnect` handshake to write. Scoping comes free from nearest-provider-wins, so a nested group takes its own members and never its parent's — the case `$watchChildren` cannot express, since it collects every matching descendant across nested boundaries. + +**The membership is a value, and that is what v3's `Set` was missing.** A coordinator subscribes to it, so a peer arriving or leaving is an event it can act on. That matters because v4 mounts on DOM insertion with no ordering guarantee: "the set of my peers" is not settled at any point in time, and an invariant over the set — one open at a time, one selected item — has to be re-checked on every change rather than established once. **Document order is the tie-breaker, deliberately**, so which peer keeps its state is a fact about the markup and not about which one happened to mount first. A disclosure written open that mounts late loses to the one before it in the DOM, and wins over the ones after it; both orders are asserted in `group.spec.ts`, which builds the Disclosure pattern end to end from this helper plus `$provide` and `subscribeContext` alone. + +Nothing sweeps disconnected members, and nothing needs to: v4 destroys a component when its element leaves the DOM, so the member's own teardown is what removes it. v3 swept on every read of `$group` because its membership was written from `mounted` and `destroyed` on a registry that outlived both. + +The helper holds a `Set` and a `Signal` and nothing else — no element index, no name keys, no global map. `createGroup()` names no group and resolves no scope: the name a v3 consumer passed was the partition key of a page-global registry, and a group whose membership is a DOM fact needs neither. + +**The two v3 consumers of `withGroup` divide on exactly that line, which is why one uses this helper and the other does not.** `Disclosure` groups by _ancestry_ — its group is a component in the DOM, and the whole difficulty was the two sides finding each other. `Data*` groups by _name_, with the nearest `DataScope` only choosing which partition table to look in and a page-wide table when there is none, so `DataRegistry` keeps a record per name in which membership is one field beside values, sources and hydration state, and it never observes membership changes. It has its own `join(group, member)` returning the same leave function for the same reason, and no members signal because nothing subscribes to one. A group that is a set of peers gets `createGroup()`; a group that is a partition of a keyed store keeps the store. + ## 6. Decorators — sugar, never a requirement No engine ships stage-3 decorators yet, so requiring them would break the no-build promise: **every decorator is a thin wrapper over a function API that works without it.** Projects that build their sources opt in; a page loading the package from an ESM CDN keeps `registerComponent`, `$provide`, `$watchChildren`, `$read`/`$write` and the magic `on` method names. diff --git a/packages/v4/package.json b/packages/v4/package.json index 7e2e945bb..ba3c7973f 100644 --- a/packages/v4/package.json +++ b/packages/v4/package.json @@ -96,6 +96,10 @@ "types": "./dist/subpaths/EVENTS.d.ts", "import": "./dist/subpaths/EVENTS.js" }, + "./createGroup": { + "types": "./dist/subpaths/createGroup.d.ts", + "import": "./dist/subpaths/createGroup.js" + }, "./getInstances": { "types": "./dist/subpaths/getInstances.d.ts", "import": "./dist/subpaths/getInstances.js" diff --git a/packages/v4/src/exports.spec.ts b/packages/v4/src/exports.spec.ts index 8bfa5e193..2aadb15bc 100644 --- a/packages/v4/src/exports.spec.ts +++ b/packages/v4/src/exports.spec.ts @@ -5,6 +5,7 @@ import { Base, DIAGNOSTICS, EVENTS, + createGroup, defineManifest, domUpdate, emitExtendable, @@ -44,6 +45,8 @@ import { type AttributeChange, type AttributeWatcher, type ContextCallback, + type Group, + type GroupMember, type ModuleRecord, type Service, type ToolkitDiagnosticCode, @@ -51,6 +54,9 @@ import { type ToolkitDiagnosticSeverity, type WebpackContextLike, } from '@studiometa/js-toolkit-v4'; +import createGroupFromSubpath, { + createGroup as namedCreateGroupFromSubpath, +} from '@studiometa/js-toolkit-v4/createGroup'; import defineManifestFromSubpath from '@studiometa/js-toolkit-v4/defineManifest'; import domUpdateFromSubpath, { domUpdate as namedDomUpdateFromSubpath, @@ -153,7 +159,7 @@ describe('the package entry points', () => { it('keeps the framework on the root entry, without the utils or removed exports', async () => { expect(typeof Base).toBe('function'); const root = (await import('@studiometa/js-toolkit-v4')) as Record; - expect(Object.keys(root)).toHaveLength(81); + expect(Object.keys(root)).toHaveLength(82); expect(root.clamp).toBeUndefined(); expect(root.smoothTo).toBeUndefined(); for (const removed of [ @@ -190,6 +196,15 @@ describe('the package entry points', () => { >(); }); + it('exports the group helper and its structural member type', () => { + expect(createGroupFromSubpath).toBe(createGroup); + expect(namedCreateGroupFromSubpath).toBe(createGroup); + expectTypeOf().toEqualTypeOf<{ readonly $el: Element }>(); + // A `Base` satisfies the member type without the group importing it. + expectTypeOf().toMatchTypeOf(); + expectTypeOf().toMatchTypeOf<{ join(member: GroupMember): () => void }>(); + }); + it('exports standalone orchestration helpers without Base wrappers', () => { expect(domUpdateFromSubpath).toBe(domUpdate); expect(namedDomUpdateFromSubpath).toBe(domUpdate); diff --git a/packages/v4/src/group.spec.ts b/packages/v4/src/group.spec.ts new file mode 100644 index 000000000..4060a5ff3 --- /dev/null +++ b/packages/v4/src/group.spec.ts @@ -0,0 +1,380 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { Base, type BaseConfig, type BaseProps, type MountedReturn } from './Base.js'; +import { createContext, type Signal } from './context.js'; +import { subscribeContext } from './context-subscription.js'; +import { createGroup, type Group } from './group.js'; +import { registerComponents } from './registry.js'; +import { getInstance, resetDom, settle } from './test-utils.js'; + +afterEach(resetDom); + +/** A member stand-in for the unit tests, which need an element and nothing else. */ +function member(el: Element): { $el: Element } { + return { $el: el }; +} + +describe('createGroup', () => { + it('publishes its members in document order whatever the join order', () => { + const root = document.createElement('div'); + root.innerHTML = ''; + document.body.append(root); + const [first, second, third] = [...root.children].map((el) => member(el)); + + const group = createGroup(); + group.join(third); + group.join(first); + group.join(second); + + expect(group.members.value).toEqual([first, second, third]); + }); + + it('removes a member through the leave function it returned', () => { + const el = document.createElement('div'); + const only = member(el); + const group = createGroup(); + + const leave = group.join(only); + expect(group.members.value).toEqual([only]); + leave(); + expect(group.members.value).toEqual([]); + }); + + it('treats membership as a set', () => { + const el = document.createElement('div'); + const only = member(el); + const group = createGroup(); + const published: ReadonlyArray[] = []; + group.members.subscribe((value) => published.push(value)); + + const leave = group.join(only); + group.join(only); + leave(); + leave(); + + // One join and one leave, whatever the number of calls. + expect(published).toEqual([[only], []]); + }); + + it('publishes a new array on every change', () => { + const root = document.createElement('div'); + root.innerHTML = ''; + document.body.append(root); + const [a, b] = [...root.children].map((el) => member(el)); + const group = createGroup(); + + group.join(a); + const before = group.members.value; + group.join(b); + + expect(group.members.value).not.toBe(before); + expect(before).toEqual([a]); + }); +}); + +// ----------------------------------------------------------------------------- +// The Disclosure pattern: a group and its members finding each other with no +// mount order guarantee, using only `createGroup()`, `$provide()` and +// `subscribeContext()`. +// ----------------------------------------------------------------------------- + +/** + * What the group exposes to its members: the members to read and one command. + * The invariant stays with the coordinator; a member only asks. + */ +interface DisclosureGroupApi { + readonly members: Signal; + join(member: Disclosure): () => void; + open(member: Disclosure): void; +} + +const DisclosureGroupContext = createContext('DisclosureGroup'); + +interface DisclosureProps extends BaseProps { + $refs: { trigger: HTMLButtonElement; panel: HTMLElement }; + $options: { open: boolean }; +} + +/** A disclosure that works alone and defers to its nearest group when one exists. */ +class Disclosure extends Base { + static config: BaseConfig = { + name: 'Disclosure', + refs: ['trigger', 'panel'], + options: { open: Boolean }, + }; + + group?: DisclosureGroupApi; + + isOpen = false; + + mounted(): MountedReturn { + this.setOpen(this.$options.open); + + // The nearest group, whenever it appears. Joining is the answer, leaving is + // its teardown, so a nearer group mounting later takes over without either + // side knowing the other's mount order. + return subscribeContext(this.$el, DisclosureGroupContext, (group) => { + this.group = group; + const leave = group.join(this); + return () => { + leave(); + this.group = undefined; + }; + }); + } + + onTriggerClick(): void { + this.toggle(); + } + + toggle(): void { + if (this.isOpen) { + this.setOpen(false); + } else if (this.group) { + this.group.open(this); + } else { + this.setOpen(true); + } + } + + setOpen(open: boolean): void { + this.isOpen = open; + this.$refs.panel.hidden = !open; + this.$refs.trigger.setAttribute('aria-expanded', String(open)); + } +} + +interface DisclosureGroupProps extends BaseProps { + $options: { multiple: boolean }; +} + +/** Owns the group invariant and nothing of its members' markup or lifecycle. */ +class DisclosureGroup extends Base { + static config: BaseConfig = { + name: 'DisclosureGroup', + options: { multiple: Boolean }, + }; + + #peers: Group = createGroup(); + + // Provided during construction, so a member that mounts first still resolves it. + api: DisclosureGroupApi = this.$provide(DisclosureGroupContext, { + members: this.#peers.members, + join: (peer) => this.#peers.join(peer), + open: (peer) => this.open(peer), + }); + + get members(): readonly Disclosure[] { + return this.#peers.members.value; + } + + /** A peer arriving or leaving can break the invariant, so re-check the members. */ + mounted(): MountedReturn { + return this.#peers.members.subscribe(() => this.reconcile(), { immediate: true }); + } + + open(peer: Disclosure): void { + if (!this.$options.multiple) { + for (const other of this.members) { + if (other !== peer) { + other.setOpen(false); + } + } + } + peer.setOpen(true); + } + + /** + * Document order decides which peer keeps its open state, so the outcome does + * not depend on which one mounted first. + */ + reconcile(): void { + if (this.$options.multiple) { + return; + } + const [, ...extra] = this.members.filter((peer) => peer.isOpen); + for (const peer of extra) { + peer.setOpen(false); + } + } +} + +registerComponents(Disclosure, DisclosureGroup); + +function disclosureMarkup(id: string, open = false): string { + return ` +
+ +
+
+ `; +} + +async function render(html: string): Promise { + const root = document.createElement('div'); + root.innerHTML = html; + document.body.append(root); + await settle(); + return root; +} + +function disclosure(root: ParentNode, id: string): Disclosure { + return getInstance(root.querySelector(`#${id}`), 'Disclosure'); +} + +function group(root: ParentNode, id: string): DisclosureGroup { + return getInstance(root.querySelector(`#${id}`), 'DisclosureGroup'); +} + +describe('a group of disclosures', () => { + it('collects its members in document order', async () => { + const root = await render(` +
+ ${disclosureMarkup('a')} + ${disclosureMarkup('b')} +
+ `); + + expect(group(root, 'grp').members).toEqual([disclosure(root, 'a'), disclosure(root, 'b')]); + expect(disclosure(root, 'a').group).toBe(group(root, 'grp').api); + }); + + it('keeps one open at a time', async () => { + const root = await render(` +
+ ${disclosureMarkup('a')} + ${disclosureMarkup('b')} +
+ `); + const a = disclosure(root, 'a'); + const b = disclosure(root, 'b'); + + a.$refs.trigger.click(); + expect([a.isOpen, b.isOpen]).toEqual([true, false]); + b.$refs.trigger.click(); + expect([a.isOpen, b.isOpen]).toEqual([false, true]); + }); + + it('works with no group above it', async () => { + const root = await render(disclosureMarkup('lonely')); + const lonely = disclosure(root, 'lonely'); + + expect(lonely.group).toBeUndefined(); + lonely.$refs.trigger.click(); + expect(lonely.isOpen).toBe(true); + }); + + it('lets an open peer that mounts later lose to the one before it in the DOM', async () => { + const root = await render(` +
+ ${disclosureMarkup('a', true)} +
+ `); + const a = disclosure(root, 'a'); + expect(a.isOpen).toBe(true); + + // The late peer follows `a` in the DOM, so `a` keeps the open state. + root.querySelector('#grp')?.insertAdjacentHTML('beforeend', disclosureMarkup('b', true)); + await settle(); + const b = disclosure(root, 'b'); + + expect(group(root, 'grp').members).toEqual([a, b]); + expect([a.isOpen, b.isOpen]).toEqual([true, false]); + }); + + it('lets an open peer that mounts later win when it precedes the others', async () => { + const root = await render(` +
+ ${disclosureMarkup('b', true)} +
+ `); + const b = disclosure(root, 'b'); + expect(b.isOpen).toBe(true); + + // Same late mount, opposite document position: the newcomer wins instead. + root.querySelector('#grp')?.insertAdjacentHTML('afterbegin', disclosureMarkup('a', true)); + await settle(); + const a = disclosure(root, 'a'); + + expect(group(root, 'grp').members).toEqual([a, b]); + expect([a.isOpen, b.isOpen]).toEqual([true, false]); + }); + + it('joins a group that mounts after its members', async () => { + const root = await render(` +
+ ${disclosureMarkup('a')} + ${disclosureMarkup('b')} +
+ `); + const a = disclosure(root, 'a'); + expect(a.group).toBeUndefined(); + + root.querySelector('#grp')?.setAttribute('data-component', 'DisclosureGroup'); + await settle(); + + expect(a.group).toBe(group(root, 'grp').api); + expect(group(root, 'grp').members).toEqual([a, disclosure(root, 'b')]); + }); + + it('gives a nested group its own members', async () => { + const root = await render(` +
+ ${disclosureMarkup('o', true)} +
+ ${disclosureMarkup('i')} +
+
+ `); + const outer = group(root, 'outer'); + const inner = group(root, 'inner'); + const o = disclosure(root, 'o'); + const i = disclosure(root, 'i'); + + expect(outer.members).toEqual([o]); + expect(inner.members).toEqual([i]); + + // The nested member's open state is the inner group's business only. + i.$refs.trigger.click(); + expect([o.isOpen, i.isOpen]).toEqual([true, true]); + }); + + it('hands a member over to a nearer group inserted later', async () => { + const root = await render(` +
+ ${disclosureMarkup('a')} + ${disclosureMarkup('b')} +
+ `); + const outer = group(root, 'outer'); + const b = disclosure(root, 'b'); + expect(outer.members).toHaveLength(2); + + const inner = document.createElement('div'); + inner.id = 'inner'; + inner.setAttribute('data-component', 'DisclosureGroup'); + root.querySelector('#outer')?.append(inner); + inner.append(b.$el); + await settle(); + + // The same instance changed hands: the subscription was re-answered, the + // member was not destroyed and rebuilt. + expect(disclosure(root, 'b')).toBe(b); + expect(outer.members).toEqual([disclosure(root, 'a')]); + expect(group(root, 'inner').members).toEqual([disclosure(root, 'b')]); + }); + + it('drops a member whose element leaves the DOM', async () => { + const root = await render(` +
+ ${disclosureMarkup('a')} + ${disclosureMarkup('b')} +
+ `); + const a = disclosure(root, 'a'); + + disclosure(root, 'b').$el.remove(); + await settle(); + + expect(group(root, 'grp').members).toEqual([a]); + }); +}); diff --git a/packages/v4/src/group.ts b/packages/v4/src/group.ts new file mode 100644 index 000000000..6843c913c --- /dev/null +++ b/packages/v4/src/group.ts @@ -0,0 +1,93 @@ +import { signal, type Signal } from './context.js'; + +/** + * What a group needs of a member: the element that decides its rank. + * + * Structural on purpose — this module never imports `Base`, so a group orders + * anything anchored to the DOM and costs a consumer no component graph. + */ +export interface GroupMember { + readonly $el: Element; +} + +/** + * A set of peers that know about each other, published as one reactive value. + * + * The membership _is_ the state. v3's `withGroup` handed out a bare `Set` with no + * value cell, so a coordinator could read its peers but never learn that one + * arrived, and every consumer built a change channel beside it. Here a peer + * joining or leaving is an observable write, which is what makes an invariant + * over the whole set — one open at a time, one selected item — enforceable + * when v4 mounts on DOM insertion with no ordering guarantee. + * + * A group is not a registry: nothing joins implicitly, and the coordinator + * that created the group decides who can reach it — usually by providing it as + * a context value, so membership is scoped by the DOM, nearest provider first. + */ +export interface Group { + /** + * The members in document order, replaced by a new array on every change so + * subscribers observe changes by identity and never share a mutable set. + */ + readonly members: Signal; + /** + * Join, and get the leave function back. + * + * Returning the teardown is what makes membership survive an unknown mount + * order: a member hands this straight back from a `subscribeContext()` + * callback, so it joins the nearest group whenever that group appears, and + * leaves again before a nearer one takes over. + * + * Membership is a set — joining twice changes nothing, and either leave + * function removes the member once. + */ + join(member: T): () => void; +} + +/** + * Document order, the only order DOM-anchored peers can agree on without + * either the coordinator or the mount sequence arbitrating it. + */ +function inDocumentOrder(a: GroupMember, b: GroupMember): number { + const position = a.$el.compareDocumentPosition(b.$el); + if (position & Node.DOCUMENT_POSITION_FOLLOWING) { + return -1; + } + if (position & Node.DOCUMENT_POSITION_PRECEDING) { + return 1; + } + return 0; +} + +/** + * Create an empty group. + * + * A member leaves through the function `join()` returned; nothing sweeps + * disconnected elements, because v4 destroys a component when its element + * leaves the DOM and the member's own teardown is what removes it. + */ +export function createGroup(): Group { + const joined = new Set(); + const members = signal([]); + + // Sorting on write keeps the published value ready to read and makes the + // new array identity the notification. + const publish = () => { + members.value = [...joined].sort(inDocumentOrder); + }; + + return { + members, + join(member: T): () => void { + if (!joined.has(member)) { + joined.add(member); + publish(); + } + return () => { + if (joined.delete(member)) { + publish(); + } + }; + }, + }; +} diff --git a/packages/v4/src/index.ts b/packages/v4/src/index.ts index 6ccd6e85b..1d5a70d8b 100644 --- a/packages/v4/src/index.ts +++ b/packages/v4/src/index.ts @@ -41,6 +41,7 @@ export { type AttributeWatcher, } from './dom-mutations.js'; export { EVENTS } from './events.js'; +export { createGroup, type Group, type GroupMember } from './group.js'; export { getInstances } from './instances.js'; export { defineManifest, diff --git a/packages/v4/src/subpaths/createGroup.ts b/packages/v4/src/subpaths/createGroup.ts new file mode 100644 index 000000000..03cfa83a5 --- /dev/null +++ b/packages/v4/src/subpaths/createGroup.ts @@ -0,0 +1 @@ +export { createGroup, createGroup as default } from '../group.js'; diff --git a/packages/v4/test/package-node-consumer.js b/packages/v4/test/package-node-consumer.js index 09af867c2..a1b46806c 100644 --- a/packages/v4/test/package-node-consumer.js +++ b/packages/v4/test/package-node-consumer.js @@ -12,6 +12,7 @@ import useMutationDefault, { useMutation } from '@studiometa/js-toolkit-v4/useMu import useRafDefault, { useRaf } from '@studiometa/js-toolkit-v4/useRaf'; import withMutationDefault, { withMutation } from '@studiometa/js-toolkit-v4/withMutation'; import watchAttributesDefault, { watchAttributes } from '@studiometa/js-toolkit-v4/watchAttributes'; +import createGroupDefault, { createGroup } from '@studiometa/js-toolkit-v4/createGroup'; import createStorageDefault, { createStorage } from '@studiometa/js-toolkit-v4/createStorage'; import createMemoryStorageProviderDefault, { createMemoryStorageProvider, @@ -53,7 +54,9 @@ assert.equal(createStorage, toolkit.createStorage); assert.equal(createStorageDefault, createStorage); assert.equal(createMemoryStorageProvider, toolkit.createMemoryStorageProvider); assert.equal(createMemoryStorageProviderDefault, createMemoryStorageProvider); -assert.equal(Object.keys(toolkit).length, 81); +assert.equal(createGroup, toolkit.createGroup); +assert.equal(createGroupDefault, createGroup); +assert.equal(Object.keys(toolkit).length, 82); assert.equal(toolkit.ToolkitErrorDetail, undefined); assert.equal(toolkit.ToolkitErrorStage, undefined); @@ -68,6 +71,16 @@ storage.set('theme', 'light'); unsubscribe(); storage.set('theme', 'dark'); assert.deepEqual(seen, ['light']); +// A group needs no DOM to hold its members: it only reads `$el` to order peers. +const group = createGroup(); +const peer = { $el: {} }; +const published = []; +group.members.subscribe((members) => published.push(members)); +const leave = group.join(peer); +assert.deepEqual(group.members.value, [peer]); +leave(); +assert.deepEqual(published, [[peer], []]); + assert.equal(clampDefault, clamp); assert.equal(clamp(12, 0, 10), 10);