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