fix(modules): core offers a contribution, never a slot name
The inverted slot direction reached exactly one module. Core filled three
literal names - uo.guild.detail, uo.guild.forum, uo.guild.header - matched by
exact name in applyCoreFills, so a second game declaring a place under its own
id got an empty page and no error. "A fill for a slot nobody declared is not an
error" is the rule that made the miss invisible, and it is the right rule; what
was wrong was core knowing a slot's name at all.
It also put a module identifier inside core, in three string literals
scripts/checkModuleIdentifiers.js masks by construction and could never catch.
Found by the integration kit while writing the chapter that teaches this shape
to an audience outside this org - which is what that phase is for.
So the module says WHERE, in its own vocabulary, and WHICH of core's
contributions goes there:
declareModuleSlot(ID, 'uo.guild.detail', { core: 'team.activity' })
and core offers into the catalogue rather than into a name:
offerCoreFill('team.activity', TeamActivityFeed)
CORE_CONTRIBUTIONS is exported and fixed at build time, so asking for one core
does not offer THROWS at the declaration. That asymmetry with an unfilled slot
is deliberate: an unknown contribution is always a typo or a version skew - the
module's coreApi range has already been checked - and the failure it would
otherwise produce is a page that renders empty forever with nothing logged.
options.core is optional; a slot that asks for nothing stays empty, which is
what a module declaring a place it fills itself wants. More than one slot may
ask for the same contribution and each gets it: how many places a module wants
its feed in is a layout decision on a page core does not own.
Amends MODULE_API 1.6.0 in place rather than adding 1.7.0 - the same rule the
eighth and ninth members were given, and 1.6.0 has only ever been on edge.
Also: the UI kit is nine exports, not eight. Slot made it nine in phase 3 and
the comment beside it still said eighth.
288 client tests, 1162 server tests.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,7 @@ import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import App from './App.jsx'
|
||||
import { publishSharedDependencies } from './modules/shared.js'
|
||||
import { declareSlot, applyCoreFills, fillModuleSlot } from './modules/registry.js'
|
||||
import { declareSlot, applyCoreFills, offerCoreFill } from './modules/registry.js'
|
||||
import TeamActivityFeed from './modules/TeamActivityFeed.jsx'
|
||||
import TeamForumPanel from './modules/TeamForumPanel.jsx'
|
||||
import TeamNotifyToggle from './modules/TeamNotifyToggle.jsx'
|
||||
@@ -72,24 +72,33 @@ declareSlot('player.invite.accepted')
|
||||
// module chunk has evaluated, which is the only moment a module-declared slot
|
||||
// exists to be filled.
|
||||
//
|
||||
// Naming a slot no installed module declares is not an error. On a deployment
|
||||
// with no game module this fill simply never lands, which is the mirror of an
|
||||
// **Core offers a CONTRIBUTION and never names a slot.** The module that owns the
|
||||
// page says where each of these goes, in its own vocabulary, by asking for one on
|
||||
// `declareModuleSlot`. Naming the slots here instead — which is how this was first
|
||||
// written — meant core's Team content reached exactly one module: any other game
|
||||
// declaring a place under its own id got an empty page and no error, because a
|
||||
// fill nobody asked for is deliberately not an error. It also put a module id
|
||||
// inside core, in string literals `scripts/checkModuleIdentifiers.js` masks by
|
||||
// construction and so could never have caught.
|
||||
//
|
||||
// Offering something nothing asks for is still not an error: a deployment with no
|
||||
// game module installed asks for none of these, which is the mirror of an
|
||||
// unfilled slot rendering nothing.
|
||||
fillModuleSlot('uo.guild.detail', TeamActivityFeed)
|
||||
offerCoreFill('team.activity', TeamActivityFeed)
|
||||
|
||||
// The forum is core's for the same reason and goes in a SECOND place the module
|
||||
// declares, rather than joining the feed in the first: a slot takes one component
|
||||
// (first fill wins), and stacking two unrelated panels into one fill would make
|
||||
// the module unable to place them separately on its own page. It also keeps the
|
||||
// two independent — a deployment with the forum switched off renders the feed
|
||||
// exactly as before.
|
||||
fillModuleSlot('uo.guild.forum', TeamForumPanel)
|
||||
// The forum is core's for the same reason and goes wherever the module asked for
|
||||
// it — a SECOND place, in module-uo's case, rather than joining the feed in the
|
||||
// first: a slot takes one component (first fill wins), and stacking two unrelated
|
||||
// panels into one contribution would make the module unable to place them
|
||||
// separately on its own page. It also keeps the two independent — a deployment
|
||||
// with the forum switched off renders the feed exactly as before.
|
||||
offerCoreFill('team.forum', TeamForumPanel)
|
||||
|
||||
// And the notification control, in a third place the module declares ABOVE its
|
||||
// roster. A third slot rather than a corner of the feed for the same reason there
|
||||
// were two: this is an action on the page and the other two are content in it,
|
||||
// and only the module can say where each belongs on a page it owns.
|
||||
fillModuleSlot('uo.guild.header', TeamNotifyToggle)
|
||||
// And the notification control. A third contribution rather than a corner of the
|
||||
// feed for the same reason there were two: this is an action on the page and the
|
||||
// other two are content in it, and only the module can say where each belongs on
|
||||
// a page it owns.
|
||||
offerCoreFill('team.notify', TeamNotifyToggle)
|
||||
|
||||
// Render on DOMContentLoaded rather than immediately, and that is the one line
|
||||
// of core's boot the module system changes.
|
||||
|
||||
@@ -135,6 +135,37 @@ export function declareSlot(name) {
|
||||
slots.set(name, { Component: null, filledBy: null })
|
||||
}
|
||||
|
||||
/**
|
||||
* The contributions core has for a module-declared slot.
|
||||
*
|
||||
* **Core offers a CONTRIBUTION, not a slot name, and that is the whole of why
|
||||
* this list exists.** The first cut of the inverted direction had core fill three
|
||||
* literal names — `uo.guild.detail` and its two siblings — which worked for
|
||||
* exactly one module and silently did nothing for any other: a second game
|
||||
* declaring `clan.detail` under its own id got an empty page and no error,
|
||||
* because "a fill for a slot nobody declared is not an error" is the rule that
|
||||
* makes an unknown name invisible. It also put a module identifier in core, in
|
||||
* three string literals `scripts/checkModuleIdentifiers.js` cannot see, since it
|
||||
* masks string bodies by construction.
|
||||
*
|
||||
* So the module says WHERE (its own slot, in its own vocabulary) and WHICH of
|
||||
* core's contributions goes there. Core never names a module id.
|
||||
*
|
||||
* Adding a member here is a **minor** MODULE_API bump. Requesting one that is not
|
||||
* here THROWS at the declaration, deliberately: unlike an unfilled slot, an
|
||||
* unknown contribution is always a typo or a version skew — core's list is fixed
|
||||
* at build time and a module's `coreApi` range has already been checked — and the
|
||||
* failure it would otherwise produce is a page that renders empty forever.
|
||||
*/
|
||||
export const CORE_CONTRIBUTIONS = Object.freeze({
|
||||
/** The Team activity feed. Core's because only core can resolve the public/members split on it. */
|
||||
'team.activity': true,
|
||||
/** The Team forum panel. Core's because membership and manual grants are core's rules. */
|
||||
'team.forum': true,
|
||||
/** The per-Team notification control. Core's because it resolves whether the viewer is in the Team. */
|
||||
'team.notify': true,
|
||||
})
|
||||
|
||||
/**
|
||||
* The INVERTED direction: a MODULE declares a slot and CORE fills it.
|
||||
*
|
||||
@@ -153,47 +184,67 @@ export function declareSlot(name) {
|
||||
* what stops two modules colliding and what makes the owner readable at the fill
|
||||
* site. The namespace is enforced rather than conventional.
|
||||
*
|
||||
* **`options.core` names which of core's contributions belongs in that place.**
|
||||
* It is optional — a module may declare a slot it fills itself, or one it keeps
|
||||
* empty for now — and it is the only thing that gets core's content into the
|
||||
* page. The place name stays the module's own word; the contribution is core's.
|
||||
*
|
||||
* **Ordering is why this is a separate call and not just `declareSlot` exposed
|
||||
* to modules.** Core's bundle evaluates BEFORE any module chunk (module scripts
|
||||
* are deferred and injected after core's), so at the moment core would like to
|
||||
* fill one of these, it does not exist yet. Core therefore registers its fills
|
||||
* through `fillModuleSlot` below, which is applied after every module chunk has
|
||||
* evaluated — see main.jsx.
|
||||
* fill one of these, it does not exist yet. Core therefore offers its
|
||||
* contributions through `offerCoreFill` below, applied after every module chunk
|
||||
* has evaluated — see main.jsx.
|
||||
*/
|
||||
export function declareModuleSlot(id, name) {
|
||||
export function declareModuleSlot(id, name, options = {}) {
|
||||
if (!name.startsWith(`${id}.`)) {
|
||||
throw new Error(`declareModuleSlot: "${name}" must be namespaced "${id}."`)
|
||||
}
|
||||
if (slots.has(name)) throw new Error(`extension slot "${name}" already declared`)
|
||||
slots.set(name, { Component: null, filledBy: null, declaredBy: id })
|
||||
const contribution = options.core ?? null
|
||||
if (contribution !== null && !Object.hasOwn(CORE_CONTRIBUTIONS, contribution)) {
|
||||
throw new Error(
|
||||
`declareModuleSlot: "${name}" asks for core contribution "${contribution}", which core does not ` +
|
||||
`offer. Known: ${Object.keys(CORE_CONTRIBUTIONS).join(', ')}.`,
|
||||
)
|
||||
}
|
||||
slots.set(name, { Component: null, filledBy: null, declaredBy: id, wants: contribution })
|
||||
}
|
||||
|
||||
// Core's pending fills for module-declared slots, applied once every module
|
||||
// chunk has evaluated. Kept as a list rather than applied eagerly because the
|
||||
// slot does not exist when core asks — see the ordering note above.
|
||||
// Core's pending contributions, applied once every module chunk has evaluated.
|
||||
// Kept as a list rather than applied eagerly because no module-declared slot
|
||||
// exists when core offers — see the ordering note above.
|
||||
const coreFills = []
|
||||
|
||||
/**
|
||||
* Core: "fill this module-declared slot when it turns up."
|
||||
* Core: "here is my <contribution>, for whichever module asked for it."
|
||||
*
|
||||
* Deliberately not an error when the slot never appears. A module that is not
|
||||
* installed declares nothing, and core offering content for a page that does not
|
||||
* exist is the ordinary case on any deployment — not a misconfiguration. That is
|
||||
* the mirror of an unfilled slot rendering nothing.
|
||||
* Deliberately not an error when nothing asked. A deployment with no game module
|
||||
* installed asks for none of these, and core offering content for a page that
|
||||
* does not exist is the ordinary case rather than a misconfiguration — the mirror
|
||||
* of an unfilled slot rendering nothing.
|
||||
*
|
||||
* More than one slot may ask for the same contribution, and each gets it. Core
|
||||
* has no reason to care how many places a module wants its feed in, and refusing
|
||||
* the second would be core making a layout decision on a page it does not own.
|
||||
*/
|
||||
export function fillModuleSlot(name, Component) {
|
||||
if (typeof Component !== 'function') throw new Error(`fillModuleSlot: ${name} is not a component`)
|
||||
coreFills.push([name, Component])
|
||||
export function offerCoreFill(contribution, Component) {
|
||||
if (!Object.hasOwn(CORE_CONTRIBUTIONS, contribution)) {
|
||||
throw new Error(`offerCoreFill: "${contribution}" is not in CORE_CONTRIBUTIONS`)
|
||||
}
|
||||
if (typeof Component !== 'function') throw new Error(`offerCoreFill: ${contribution} is not a component`)
|
||||
coreFills.push([contribution, Component])
|
||||
}
|
||||
|
||||
/** Apply core's fills. Called once from main.jsx, after module chunks have run. */
|
||||
/** Apply core's contributions. Called once from main.jsx, after module chunks have run. */
|
||||
export function applyCoreFills() {
|
||||
for (const [name, Component] of coreFills) {
|
||||
const entry = slots.get(name)
|
||||
if (!entry) continue // the declaring module is not installed
|
||||
if (entry.filledBy) continue // a module already claimed it; first fill wins
|
||||
entry.Component = Component
|
||||
entry.filledBy = 'core'
|
||||
for (const [contribution, Component] of coreFills) {
|
||||
for (const entry of slots.values()) {
|
||||
if (entry.wants !== contribution) continue
|
||||
if (entry.filledBy) continue // a module already claimed it; first fill wins
|
||||
entry.Component = Component
|
||||
entry.filledBy = 'core'
|
||||
}
|
||||
}
|
||||
coreFills.length = 0
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ const ui = {
|
||||
useAsync,
|
||||
useAuth,
|
||||
useSite,
|
||||
// The eighth member, for the INVERTED slot direction (TEAMS.md Part 3). A
|
||||
// The ninth member, for the INVERTED slot direction (TEAMS.md Part 3). A
|
||||
// module that declares a slot on its own page needs the same component core
|
||||
// renders its own with — the error boundary in particular, since the thing
|
||||
// being contained here is CORE's content failing inside the MODULE's page.
|
||||
|
||||
Reference in New Issue
Block a user