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>
147 lines
8.2 KiB
JavaScript
147 lines
8.2 KiB
JavaScript
import React from 'react'
|
|
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, offerCoreFill } from './modules/registry.js'
|
|
import TeamActivityFeed from './modules/TeamActivityFeed.jsx'
|
|
import TeamForumPanel from './modules/TeamForumPanel.jsx'
|
|
import TeamNotifyToggle from './modules/TeamNotifyToggle.jsx'
|
|
import './styles/theme.css'
|
|
|
|
// Publish window.__rg BEFORE rendering and before any module chunk evaluates.
|
|
// Installed modules arrive as `<script type="module" src="/modules/<id>/…">`
|
|
// tags the server injects into the shell (server/src/utils/htmlShell.js), placed
|
|
// after this bundle's own tag; module scripts execute in document order, so they
|
|
// resolve their externals against the global this call sets up
|
|
// (docs/website/MODULE_API.md §3.2).
|
|
publishSharedDependencies()
|
|
|
|
// Core registered a feature provider here until slice 3, under owner id `core`
|
|
// and namespace `uo`, so that the seam was exercised by real content from the
|
|
// day it was built. That prediction paid out exactly as written: the extraction
|
|
// deleted the registration and the hook it named, and SiteHeader was not touched.
|
|
|
|
// ── Extension slots (MODULE_API.md §3.7) ───────────────────────────────────
|
|
//
|
|
// Declared HERE, in core's own bundle, which is what makes the ordering a fact
|
|
// rather than a hope: module chunks are deferred scripts the shell injects after
|
|
// this one (§3.1), so a module can never reach registerExtension before the slot
|
|
// it names exists. "Unknown slot" therefore always means a typo or a version
|
|
// skew, never a load-order accident — which is why that case throws.
|
|
//
|
|
// Both slots are named for a PLACE, not for a meaning. `site.footer.status` is
|
|
// the spot in the footer's info row, not a declaration that core knows what a
|
|
// game server's status is; the label, the target and whether anything renders at
|
|
// all belong to whoever fills it. A slot typed by its content would put game
|
|
// semantics back into core, which is the thing Phase 3 takes out.
|
|
declareSlot('site.footer.status')
|
|
// Deliberately the same name as the server's slot (MODULE_API.md §2.4): one
|
|
// resource, one extension point, two halves. The module with routes under
|
|
// /api/v1/admin/users/:id is the module with something to show on that page.
|
|
declareSlot('admin.users.detail')
|
|
// The invite-acceptance page's optional next step. Core owns invites — staff are
|
|
// invited too — and owned the game-account step inside them until slice 3, which
|
|
// meant core reading a `gameAccountSignup` flag and posting to a shard route.
|
|
//
|
|
// Named for the place, like the other two: it is "the point after an invite has
|
|
// been accepted and before the invitee is sent on", not "create a game account".
|
|
// Whether there is a step at all is the filling module's decision, made from
|
|
// data core does not have; core renders the shell and a skip control, and hands
|
|
// over `onDone`. With the slot unfilled the invitee goes straight to the portal,
|
|
// which is what core's own code did whenever the flag was off.
|
|
declareSlot('player.invite.accepted')
|
|
|
|
// Core filled the first two itself until slice 3, with the components that were
|
|
// inline in SiteFooter.jsx and UserDetail.jsx. Both are gone: the module fills
|
|
// all three, and core's own fills had to go for it to be able to — the first
|
|
// fill wins, and core registered first (§3.7).
|
|
|
|
// ── The inverted direction: core fills a MODULE's slot ─────────────────────
|
|
//
|
|
// Teams is a contract PRIMITIVE, not a surface (TEAMS.md Part 3). Core owns the
|
|
// tables, the sync, the access rules and the activity feed; it does not own the
|
|
// word for one — a UO shard says guild, and the module that comes after it will
|
|
// say clan. So core publishes no Team page and no Team nav row, and the module
|
|
// that owns the vocabulary owns the page.
|
|
//
|
|
// The activity feed is the one piece of that page core cannot hand over: only
|
|
// core can resolve whether this viewer is inside the Team, and the public/members
|
|
// split is a security boundary. So the module declares the place and core fills
|
|
// it. Registered here, applied at mount — `applyCoreFills` runs after every
|
|
// module chunk has evaluated, which is the only moment a module-declared slot
|
|
// exists to be filled.
|
|
//
|
|
// **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.
|
|
offerCoreFill('team.activity', TeamActivityFeed)
|
|
|
|
// 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. 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.
|
|
//
|
|
// Deferred scripts — which every `type="module"` script is — execute in document
|
|
// order and ALL of them finish before DOMContentLoaded fires. Waiting for that
|
|
// event is therefore the guarantee that every installed module has registered
|
|
// its routes before React reads the registry: no loading state, no re-render,
|
|
// and no ordering race between core's bundle and a module's. A module chunk that
|
|
// 404s or throws does not hold the event back, so a broken module costs its own
|
|
// pages and not the site.
|
|
//
|
|
// The readyState check below is `'complete'`, and it is not the obvious
|
|
// `'loading'`. A DEFERRED script — which every `type="module"` script is — runs
|
|
// after the document has been parsed, so by the time this line executes
|
|
// readyState is already `'interactive'`; DOMContentLoaded has NOT fired yet and
|
|
// still comes after every deferred script. Testing for `'loading'` therefore
|
|
// mounts immediately, before any module chunk has evaluated, and a module's
|
|
// routes are missing from the very first render — which looks exactly like a
|
|
// module that failed to load: its URL falls through to core's catch-all and
|
|
// redirects home. Found by loading a real chunk in a browser; no unit test in
|
|
// this repo can see it.
|
|
//
|
|
// `'complete'` is only reached after `load`, which is strictly later than any
|
|
// static deferred script, so this branch is the genuine "the event has already
|
|
// been and gone" case and not a wrong guess about our own timing.
|
|
function mount() {
|
|
// Every module chunk has evaluated by now, so any slot a module declared is
|
|
// present and core's pending fills can land. Must happen before the first
|
|
// render: `extensionFor` is read during render and there is no subscription.
|
|
applyCoreFills()
|
|
createRoot(document.getElementById('root')).render(
|
|
<React.StrictMode>
|
|
<BrowserRouter>
|
|
<App />
|
|
</BrowserRouter>
|
|
</React.StrictMode>,
|
|
)
|
|
}
|
|
|
|
if (document.readyState === 'complete') {
|
|
mount()
|
|
} else {
|
|
document.addEventListener('DOMContentLoaded', mount, { once: true })
|
|
}
|