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'
|
||||
|
||||
Reference in New Issue
Block a user