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>
112 lines
5.2 KiB
JavaScript
112 lines
5.2 KiB
JavaScript
// ── window.__rg — the shared-dependency global ─────────────────────────────
|
|
//
|
|
// Phase 2, PR 7 of docs/website/MODULE_SYSTEM.md §2.7; the normative shape is
|
|
// docs/website/MODULE_API.md §3.2.
|
|
//
|
|
// A module's client half is a PREBUILT ESM chunk — the operator never builds
|
|
// anything (MODULE_SYSTEM.md §1.14) — served same-origin and loaded under
|
|
// `script-src 'self'` with no 'unsafe-inline'. That combination is what rules out
|
|
// an import map: an import map has to be an inline `<script type="importmap">`,
|
|
// and the policy forbids inline scripts outright. So the shared dependencies ride
|
|
// on a global, and the module's Rollup externals are aliased to two-line shims
|
|
// that re-export from it (§3.6).
|
|
//
|
|
// **There is exactly one React in the page and core owns it.** A module that
|
|
// bundled its own would get a second hook dispatcher and fail at its first
|
|
// useState. That is the same rule the server half enforces for `express` and
|
|
// `express-validator` on `ctx`, and for the same reason: anything shared between
|
|
// core and a module is owned by core and HANDED OVER, never resolved by the
|
|
// module.
|
|
|
|
import * as react from 'react'
|
|
import * as reactDom from 'react-dom/client'
|
|
import * as router from 'react-router-dom'
|
|
// The automatic JSX runtime, and it is not decoration. A module's bundler
|
|
// compiles every .jsx file to imports from `react/jsx-runtime` under the modern
|
|
// default, and those have to resolve to CORE's React like every other import.
|
|
// Without it here a module would have to build with `jsxRuntime: 'classic'`;
|
|
// with it, a module uses the default its tooling already assumes.
|
|
import * as jsxRuntime from 'react/jsx-runtime'
|
|
|
|
import { registry } from './registry.js'
|
|
import { MODULE_API_VERSION } from './version.js'
|
|
|
|
import PublicLayout from '../components/PublicLayout.jsx'
|
|
import PageHeader from '../components/PageHeader.jsx'
|
|
import { Loading, ErrorState, EmptyState } from '../components/PageState.jsx'
|
|
import Slot from './Slot.jsx'
|
|
import { useAsync } from '../lib/useAsync.js'
|
|
import { useAuth } from '../contexts/AuthContext.jsx'
|
|
import { useSite } from '../contexts/SiteContext.jsx'
|
|
import { request, ApiError, BASE } from '../api/client.js'
|
|
|
|
// The UI kit is CURATED AND CLOSED (§3.4), not a re-export of components/. These
|
|
// eight exports — five table rows in §3.4, since `PageState` contributes three —
|
|
// are what the smallest UO page already needs beyond React and the router:
|
|
// without them a module either reaches into core's tree — violating the
|
|
// zero-import rule the whole boundary rests on — or ships its own copies, which
|
|
// means a module page that does not look like the site it is installed in, and
|
|
// that drifts further every time core's layout changes.
|
|
//
|
|
// Adding a member is a MINOR MODULE_API_VERSION bump; changing a member's props
|
|
// is a MAJOR one. That is a real constraint on core's own refactoring and it is
|
|
// the price of the boundary being worth anything.
|
|
//
|
|
// `AdminPage` was in an early draft of §3.4's table and is deliberately absent:
|
|
// core has no such component — admin views are plain markup inside AdminLayout —
|
|
// and inventing one to satisfy a table would be a core change with no consumer
|
|
// until Phase 3. The contract was amended rather than the code padded (it no
|
|
// longer lists it), and adding it later costs a minor bump, which is exactly the
|
|
// case the versioning is for.
|
|
const ui = {
|
|
PublicLayout,
|
|
PageHeader,
|
|
Loading,
|
|
ErrorState,
|
|
EmptyState,
|
|
useAsync,
|
|
useAuth,
|
|
useSite,
|
|
// 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.
|
|
// Shared rather than reimplemented for the reason the whole kit exists: two
|
|
// boundaries with different behaviour would be two bugs.
|
|
Slot,
|
|
}
|
|
|
|
// The request PRIMITIVE, not the `api` object (§3.5): a module builds its own
|
|
// namespace over `request` and owns the paths it calls, which is right, because
|
|
// it owns the routes at the other end.
|
|
//
|
|
// `BASE` was in §3.5 from the start and missing from this object until slice 3,
|
|
// which is when something first needed it. `request` is fetch-only, so an
|
|
// EventSource — the shard's live feed is two of them — has to build its own URL,
|
|
// and the alternative is a module hardcoding `/api/v1`: an assertion about where
|
|
// core mounts its API that core has never promised to keep.
|
|
const api = { request, ApiError, BASE }
|
|
|
|
/**
|
|
* Publish `window.__rg`. Called by main.jsx before it renders, and before any
|
|
* module chunk evaluates.
|
|
*
|
|
* Frozen, one level down as well as at the top: the object a module reaches for
|
|
* its React is not somewhere a module gets to leave something for the next one.
|
|
* Cross-module communication is a thing the contract does not have, and an
|
|
* unfrozen global is how a codebase acquires one by accident.
|
|
*/
|
|
export function publishSharedDependencies() {
|
|
window.__rg = Object.freeze({
|
|
version: MODULE_API_VERSION,
|
|
react,
|
|
reactDom,
|
|
router,
|
|
jsxRuntime,
|
|
registry,
|
|
ui: Object.freeze(ui),
|
|
api: Object.freeze(api),
|
|
})
|
|
return window.__rg
|
|
}
|