feat(modules): client extension slots (phase 3, slice 2)
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> and gets nothing back when the slot is unfilled, so an instance with no module installed renders exactly what it rendered before -- the same untouched-path guarantee withModuleNav makes. A slot is named for a PLACE, never for a meaning. Core supplies the position and the styling; 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 phase 3 exists to remove. This is the one place the client registry is not fail-open. An unknown slot, a non-component and a second fill all throw, matching checkExtensionShape server-side, because a dropped nav row costs a link the viewer can reach another way while a silently dropped extension is invisible to everyone including its author. A throw is always a programming error and never a race: core declares in its own bundle and every module chunk is a deferred script injected after it. Reading stays fail-safe -- undeclared and unfilled both read null -- and a filling component renders inside an error boundary. That asymmetry is where the client differs from the server: a module route that throws costs the module's own page, but an extension throws inside CORE's, and the whole reason core keeps ownership of that page is that it stays usable. Core decorates a slot through <Slot wrap>, not by asking whether it is filled. The obvious alternative is right about the unfilled case and wrong about the failed one -- the extension is filled, so the separator renders, and then the component throws into the boundary and leaves the separator behind on its own. wrap puts core's decoration inside the boundary where it shares the extension's fate. Found in a browser, with the footer's separator, which is the only place either could have been found. MODULE_API_VERSION 1.1.0 -> 1.2.0, both halves: the two state ONE version. Contract: docs/website/MODULE_API.md 3.7. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
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