diff --git a/client/src/modules/Slot.jsx b/client/src/modules/Slot.jsx new file mode 100644 index 0000000..1ae2d92 --- /dev/null +++ b/client/src/modules/Slot.jsx @@ -0,0 +1,68 @@ +// ── — where core renders a module's content ───────────────────────── +// +// Phase 3, slice 2 of docs/website/MODULE_SYSTEM.md §2.7.1; the normative +// contract is docs/website/MODULE_API.md §3.7. +// +// The read side of registry.js's extension slots. Core puts one of these where a +// module may contribute to a core page, and gets back either the filling +// component with the props core passed, or nothing at all. +// +// **Nothing at all is the important half.** An instance with no module installed +// renders the identical page it renders today, which is the same untouched-path +// guarantee `withModuleNav` makes for nav — and the reason a core layout can +// place a slot without also acquiring an empty-state to design. + +import React from 'react' +import { extensionFor } from './registry.js' + +/** + * Contain a module's render failure to the module's own section. + * + * This is where the client differs from the server, deliberately. A module + * *route* that throws costs the module's own page and core does not need to care. + * An extension throws inside CORE's page — the admin's user detail, the site + * footer — and the whole reason core keeps ownership of that page is that it + * stays usable. So a slot renders nothing and logs, rather than taking the + * surrounding page down with it. + * + * A class because that is what React gives us: there is no hook form of + * componentDidCatch, and this is the only error boundary core has. + */ +class SlotBoundary extends React.Component { + constructor(props) { + super(props) + this.state = { failed: false } + } + + static getDerivedStateFromError() { + return { failed: true } + } + + componentDidCatch(error) { + // Named so the console says whose fault it is: a blank section with an + // anonymous stack is how a module bug becomes core's support ticket. + console.error(`[modules] extension in slot "${this.props.name}" threw and was dropped`, error) + } + + render() { + return this.state.failed ? null : this.props.children + } +} + +/** + * @param {string} name the slot id, declared by core in main.jsx + * @param {function} [wrap] core markup that only makes sense AROUND a rendered + * extension — a separator, a heading, a rule. Called with the extension's + * element and rendered inside the boundary, so it shares the extension's fate: + * an unfilled slot and a failed one both render nothing at all, decoration + * included. Found in a browser, because the obvious alternative — asking + * whether the slot is filled and rendering the separator alongside — is right + * about the unfilled case and leaves a stray separator behind on the failed one. + * @param {object} props everything else is handed to the filling component + */ +export default function Slot({ name, wrap, ...props }) { + const Extension = extensionFor(name) + if (!Extension) return null + const element = + return {wrap ? wrap(element) : element} +} diff --git a/client/src/modules/registry.js b/client/src/modules/registry.js index bdc7892..87d7a03 100644 --- a/client/src/modules/registry.js +++ b/client/src/modules/registry.js @@ -32,6 +32,8 @@ const routes = { public: [], admin: [], player: [] } const nav = { public: [], admin: [], player: [] } const providers = new Map() +// slot name → { Component, filledBy }. +const slots = new Map() const registered = new Set() const AREAS = ['public', 'admin', 'player'] @@ -100,6 +102,70 @@ export function registerFeatureProvider(id, namespace, hook) { registered.add(id) } +// ── Extension slots (§3.7) ───────────────────────────────────────────────── +// +// The client twin of the server's declareSlot/registerExtension, and the same +// rule in both halves: core declares a slot, ONLY core declares one, and at most +// one module fills it. Core renders `` (Slot.jsx) and gets nothing +// back when the slot is unfilled — so an instance with no module installed +// renders exactly what it renders today. +// +// A slot is named for a PLACE, never for a meaning. `site.footer.status` is a +// position in the footer and the styling that goes with it; the label, the +// target, the data and whether anything renders at all are the module's. The +// moment core types a slot by its content it has re-acquired the game semantics +// this whole extraction removes. + +/** + * @param {string} name the slot id. Core-only — deliberately not on the + * `registry` object handed to modules. + */ +export function declareSlot(name) { + if (slots.has(name)) throw new Error(`extension slot "${name}" already declared`) + slots.set(name, { Component: null, filledBy: null }) +} + +/** + * Fill a declared slot with a component. + * + * **This is the one place the client registry is not fail-open**, and the + * asymmetry is deliberate. A dropped nav row costs a link the viewer can reach + * another way; a silently dropped extension is invisible to everyone including + * its author. So an unknown slot, a non-component, and a second fill all throw — + * exactly as the server's checkExtensionShape does. + * + * A throw here is always a programming error and never a race, because + * declaration structurally precedes filling: core declares in main.jsx, inside + * its own bundle, and every module chunk is a deferred script injected after it + * (§3.1). + */ +export function registerExtension(id, slot, Component) { + const entry = slots.get(slot) + if (!entry) throw new Error(`registerExtension: unknown extension slot "${slot}"`) + if (typeof Component !== 'function') throw new Error(`registerExtension: ${slot} is not a component`) + if (entry.filledBy) throw new Error(`extension slot "${slot}" is already filled by "${entry.filledBy}"`) + entry.Component = Component + entry.filledBy = id + registered.add(id) +} + +/** + * The filling component, or null. + * + * Read by Slot.jsx and nothing else — deliberately. There is no `hasExtension` + * for a core layout to branch on, because a layout that asks whether a slot is + * filled and then renders its own decoration alongside gets the *failed* case + * wrong: the extension is filled, so the decoration renders, and the component + * then throws into the boundary leaving the decoration behind on its own. Core + * decorates through `` instead, which puts the decoration inside the + * boundary where it shares the extension's fate. (Found in a browser, with the + * footer's separator.) + * + * Undeclared and unfilled both read null: reading is fail-safe, and only writing + * is strict. + */ +export const extensionFor = (slot) => (slots.get(slot) || {}).Component || null + export const routesFor = (area) => routes[area] || [] // Sorted by the `order` a module asked for. Array#sort is stable in every engine @@ -131,6 +197,11 @@ export function _reset() { nav[area].length = 0 } providers.clear() + // Declarations go too, unlike the server's, where a slot is declared once at + // require time by the router that owns it. Core declares its slots in + // main.jsx — the one file no test loads — so on this side there is nothing + // declared at import time for a surviving declaration to protect. + slots.clear() registered.clear() } @@ -142,6 +213,7 @@ export const registry = { registerRoutes, registerNav, registerFeatureProvider, + registerExtension, routesFor, navFor, featureProviderFor, diff --git a/client/src/modules/version.js b/client/src/modules/version.js index b527fe5..466b4c9 100644 --- a/client/src/modules/version.js +++ b/client/src/modules/version.js @@ -11,9 +11,13 @@ // that the two files can drift, so a test asserts they agree // (client/test/moduleRegistry.test.js) rather than trusting a bump to remember // both. +// 1.2.0 — `registry` gained `registerExtension` and core gained extension slots +// (MODULE_API.md §3.7). The first change to window.__rg since 1.0.0, and an +// addition: a module that never fills a slot is unaffected. The server half is +// untouched and bumps anyway, for the reason below. // 1.1.0 — the server's ctx gained activity.log, users.getById, site.baseUrl and // the rate-limit factory (MODULE_API.md §2.3). Nothing on window.__rg changed, // but the two halves state ONE version: a module declares a single coreApi range // and is served one chunk, so a client that claimed 1.0.0 while the server // answered 1.1.0 would be two answers to one question. -export const MODULE_API_VERSION = '1.1.0' +export const MODULE_API_VERSION = '1.2.0' diff --git a/client/test/moduleRegistry.test.js b/client/test/moduleRegistry.test.js index 49c6153..ffd78b1 100644 --- a/client/test/moduleRegistry.test.js +++ b/client/test/moduleRegistry.test.js @@ -162,6 +162,7 @@ test('the registry object handed to modules exposes the whole surface', () => { assert.deepEqual(Object.keys(registry).sort(), [ 'featureProviderFor', 'navFor', + 'registerExtension', 'registerFeatureProvider', 'registerNav', 'registerRoutes', diff --git a/client/test/moduleSlots.test.js b/client/test/moduleSlots.test.js new file mode 100644 index 0000000..d0540e4 --- /dev/null +++ b/client/test/moduleSlots.test.js @@ -0,0 +1,94 @@ +import { test, beforeEach } from 'node:test' +import assert from 'node:assert/strict' + +import { + registry, + declareSlot, + registerExtension, + extensionFor, + registeredIds, + _reset, +} from '../src/modules/registry.js' + +// Client extension slots (docs/website/MODULE_API.md §3.7) — the client twin of +// the server's declareSlot/registerExtension. +// +// The registry half only. `` itself renders, and there is no DOM in this +// runner, so what it does with what these functions return — including the error +// boundary — is proved by the §7.7 browser smoke instead. Everything below is a +// rule that can be stated without rendering anything, and every one of them can +// be got wrong in a way a browser check would not obviously catch. + +beforeEach(() => _reset()) + +const Fake = () => null +const Other = () => null + +test('an unfilled slot reads as nothing', () => { + // The guarantee core's layouts rest on: place a slot, install no module, and + // the page renders what it rendered before. + declareSlot('site.footer.status') + assert.equal(extensionFor('site.footer.status'), null) +}) + +test('an undeclared slot reads as nothing rather than throwing', () => { + // Reading is core's side and stays fail-safe: a typo in a layout costs that + // spot, not the page. Only WRITING is strict, which is the next test. + assert.equal(extensionFor('nope'), null) +}) + +test('a module fills a declared slot and core reads it back', () => { + declareSlot('admin.users.detail') + registerExtension('uo', 'admin.users.detail', Fake) + assert.equal(extensionFor('admin.users.detail'), Fake) + assert.deepEqual(registeredIds(), ['uo']) +}) + +test('filling an unknown slot throws, naming the slot', () => { + // This is the one place the client registry is NOT fail-open, and the reason + // is asymmetry of consequence: a dropped nav row costs a link the viewer can + // reach another way, a silently dropped extension is invisible to everyone + // including its author. Declaration structurally precedes filling (§3.1), so + // this can only ever be a typo or a version skew. + assert.throws(() => registerExtension('uo', 'site.footer.sttaus', Fake), /unknown extension slot "site\.footer\.sttaus"/) +}) + +test('a non-component fill throws', () => { + declareSlot('site.footer.status') + assert.throws(() => registerExtension('uo', 'site.footer.status', { render: true }), /is not a component/) +}) + +test('a second module cannot take a filled slot, and the first keeps it', () => { + // Matches the server's rule exactly (registries.js): first fill wins, second + // is an error. The second half of the assertion is the one that matters — a + // rejected fill must not have half-replaced the incumbent. + declareSlot('admin.users.detail') + registerExtension('uo', 'admin.users.detail', Fake) + assert.throws(() => registerExtension('other', 'admin.users.detail', Other), /already filled by "uo"/) + assert.equal(extensionFor('admin.users.detail'), Fake) +}) + +test('declaring a slot twice throws', () => { + // Core-side programming error: two owners for one position means whichever + // module registered first wins by file order. + declareSlot('site.footer.status') + assert.throws(() => declareSlot('site.footer.status'), /already declared/) +}) + +test('core fills a slot through the same seam a module uses', () => { + // The client twin of registries.registerCore(). Core is a registrant with an + // id like any other, which is what makes slice 3 a deletion: the module + // registers the same slot and core drops its line. + declareSlot('site.footer.status') + registerExtension('core', 'site.footer.status', Fake) + assert.deepEqual(registeredIds(), ['core']) +}) + +test('declareSlot and extensionFor are not on the module-facing registry', () => { + // Declaring is core's alone (§3.7), and reading who filled a slot is core's + // too — the same line featureProviders() draws. registerExtension IS on the + // object, because filling is the whole point. + assert.equal(registry.declareSlot, undefined) + assert.equal(registry.extensionFor, undefined) + assert.equal(typeof registry.registerExtension, 'function') +}) diff --git a/server/src/modules/version.js b/server/src/modules/version.js index f58c441..24b34bc 100644 --- a/server/src/modules/version.js +++ b/server/src/modules/version.js @@ -9,11 +9,18 @@ // Deliberately separate from PROTOCOL_VERSION (which versions the shard wire and // has nothing to say about a website module) and from any module's own version. +// 1.2.0 — the CLIENT registry gained `registerExtension` and core gained client +// extension slots (MODULE_API.md §3.7): the twin of this half's declareSlot / +// registerExtension, for module content inside a core *page* rather than under a +// core route prefix. Nothing on the server changed, and this file bumps anyway — +// the two halves state ONE version, because a module declares a single `coreApi` +// range and is served one chunk (client/src/modules/version.js). +// // 1.1.0 — `ctx` gained `activity.log`, `users.getById` and `site.baseUrl`, each // because module-uo's extraction needed it and none of them could be vendored: // an admin action a module performs belongs in core's one audit log, the // extension slot needs the user its prefix names, and §2.7 forbids a module // reading core's `APP_BASE_URL` for itself. Additions only, so minor. -const MODULE_API_VERSION = '1.1.0' +const MODULE_API_VERSION = '1.2.0' module.exports = { MODULE_API_VERSION }