feat(modules): client extension slots (phase 3, slice 2) #138
68
client/src/modules/Slot.jsx
Normal file
68
client/src/modules/Slot.jsx
Normal 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>
|
||||
}
|
||||
@@ -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 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] || []
|
||||
|
||||
// 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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
|
||||
94
client/test/moduleSlots.test.js
Normal file
94
client/test/moduleSlots.test.js
Normal 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')
|
||||
})
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user