feat(modules): client extension slots (phase 3, slice 2) #138

Merged
whitlocktech merged 2 commits from feature/module-client-slots into edge 2026-08-11 21:53:44 +00:00
6 changed files with 248 additions and 2 deletions
Showing only changes of commit 1d1350558b - Show all commits

View File

@@ -0,0 +1,68 @@
// ── <Slot> — 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 = <Extension {...props} />
return <SlotBoundary name={name}>{wrap ? wrap(element) : element}</SlotBoundary>
}

View File

@@ -32,6 +32,8 @@
const routes = { public: [], admin: [], player: [] } const routes = { public: [], admin: [], player: [] }
const nav = { public: [], admin: [], player: [] } const nav = { public: [], admin: [], player: [] }
const providers = new Map() const providers = new Map()
// slot name → { Component, filledBy }.
const slots = new Map()
const registered = new Set() const registered = new Set()
const AREAS = ['public', 'admin', 'player'] const AREAS = ['public', 'admin', 'player']
@@ -100,6 +102,70 @@ export function registerFeatureProvider(id, namespace, hook) {
registered.add(id) 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 name>` (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 `<Slot wrap>` 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] || [] export const routesFor = (area) => routes[area] || []
// Sorted by the `order` a module asked for. Array#sort is stable in every engine // 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 nav[area].length = 0
} }
providers.clear() 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() registered.clear()
} }
@@ -142,6 +213,7 @@ export const registry = {
registerRoutes, registerRoutes,
registerNav, registerNav,
registerFeatureProvider, registerFeatureProvider,
registerExtension,
routesFor, routesFor,
navFor, navFor,
featureProviderFor, featureProviderFor,

View File

@@ -11,9 +11,13 @@
// that the two files can drift, so a test asserts they agree // that the two files can drift, so a test asserts they agree
// (client/test/moduleRegistry.test.js) rather than trusting a bump to remember // (client/test/moduleRegistry.test.js) rather than trusting a bump to remember
// both. // 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 // 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, // 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 // 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 // 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. // 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'

View File

@@ -162,6 +162,7 @@ test('the registry object handed to modules exposes the whole surface', () => {
assert.deepEqual(Object.keys(registry).sort(), [ assert.deepEqual(Object.keys(registry).sort(), [
'featureProviderFor', 'featureProviderFor',
'navFor', 'navFor',
'registerExtension',
'registerFeatureProvider', 'registerFeatureProvider',
'registerNav', 'registerNav',
'registerRoutes', 'registerRoutes',

View File

@@ -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. `<Slot>` 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')
})

View File

@@ -9,11 +9,18 @@
// Deliberately separate from PROTOCOL_VERSION (which versions the shard wire and // 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. // 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 // 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: // 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 // 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 // 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. // 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 } module.exports = { MODULE_API_VERSION }