fix(modules): core offers a contribution, never a slot name
All checks were successful
PR Checks / client-build (pull_request) Successful in 46s
PR Checks / server-tests (pull_request) Successful in 2m50s
PR Checks / bot-tests (pull_request) Successful in 9m2s

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:
2026-08-19 01:15:29 -05:00
parent 9619fdf1e1
commit 335d69d122
4 changed files with 163 additions and 64 deletions

View File

@@ -5,7 +5,8 @@ import {
registry,
declareSlot,
declareModuleSlot,
fillModuleSlot,
offerCoreFill,
CORE_CONTRIBUTIONS,
applyCoreFills,
registerExtension,
extensionFor,
@@ -112,34 +113,72 @@ test('a module-declared slot must be namespaced under the declaring module', ()
assert.doesNotThrow(() => declareModuleSlot('uo', 'uo.guild.detail'))
})
test('core fills a module slot only after the module has declared it', () => {
// The ordering that makes this a separate call: core's bundle evaluates BEFORE
// any module chunk, so at the moment core registers its fill the slot does not
// exist yet. Filling eagerly would silently do nothing.
fillModuleSlot('uo.guild.detail', Feed)
assert.equal(extensionFor('uo.guild.detail'), null, 'not filled before the module declared it')
declareModuleSlot('uo', 'uo.guild.detail')
assert.equal(extensionFor('uo.guild.detail'), null, 'and not before the fills are applied')
test('core offers a contribution and the module says where it goes', () => {
// The ordering that makes this two calls: core's bundle evaluates BEFORE any
// module chunk, so at the moment core offers, no module-declared slot exists.
offerCoreFill('team.activity', Feed)
declareModuleSlot('uo', 'uo.guild.detail', { core: 'team.activity' })
assert.equal(extensionFor('uo.guild.detail'), null, 'not before the fills are applied')
applyCoreFills()
assert.equal(extensionFor('uo.guild.detail'), Feed)
})
test('a fill for a slot nobody declared is not an error', () => {
// The module is not installed. Core offering content for a page that does not
// exist is the ordinary case on any deployment, not a misconfiguration — the
// mirror of an unfilled slot rendering nothing.
fillModuleSlot('rust.clan.detail', Feed)
test('core names no slot, so a second game gets the same content in its own words', () => {
// The defect this replaced: core used to fill three literal `uo.guild.*` names,
// which reached exactly one module. Every other game declared a place under its
// own id and got an empty page with no error, because a fill nobody declared is
// deliberately not an error — the rule that makes an unknown name invisible.
offerCoreFill('team.activity', Feed)
declareModuleSlot('examplegame', 'examplegame.clan.detail', { core: 'team.activity' })
applyCoreFills()
assert.equal(extensionFor('examplegame.clan.detail'), Feed)
})
test('two modules can ask for the same contribution, and both get it', () => {
// Core has no reason to care how many places want its feed, and refusing the
// second would be core making a layout decision on a page it does not own.
offerCoreFill('team.activity', Feed)
declareModuleSlot('uo', 'uo.guild.detail', { core: 'team.activity' })
declareModuleSlot('uo', 'uo.guild.summary', { core: 'team.activity' })
applyCoreFills()
assert.equal(extensionFor('uo.guild.detail'), Feed)
assert.equal(extensionFor('uo.guild.summary'), Feed)
})
test('a slot that asks for nothing stays empty', () => {
// Optional on purpose: a module may declare a place it fills itself, or one it
// is keeping for later. Neither is core's business.
offerCoreFill('team.activity', Feed)
declareModuleSlot('uo', 'uo.guild.detail')
applyCoreFills()
assert.equal(extensionFor('uo.guild.detail'), null)
})
test('asking for a contribution core does not offer THROWS', () => {
// The asymmetry with an unfilled slot, and it is deliberate. An unknown
// contribution is always a typo or a version skew — core's list is fixed at
// build time and the module's coreApi range has already been checked — and the
// alternative failure is a page that renders empty forever with nothing logged.
assert.throws(
() => declareModuleSlot('uo', 'uo.guild.detail', { core: 'team.activityfeed' }),
/does not offer/,
)
assert.ok(CORE_CONTRIBUTIONS['team.activity'], 'the catalogue is exported so a test can name it')
})
test('a contribution nothing asks for is not an error', () => {
// No game module installed. Core offering content for a page that does not
// exist is the ordinary case on any deployment, not a misconfiguration.
offerCoreFill('team.forum', Feed)
assert.doesNotThrow(() => applyCoreFills())
assert.equal(extensionFor('rust.clan.detail'), null)
})
test('a module that fills its own slot first keeps it', () => {
const Own = () => null
declareModuleSlot('uo', 'uo.guild.detail')
declareModuleSlot('uo', 'uo.guild.detail', { core: 'team.activity' })
registerExtension('uo', 'uo.guild.detail', Own)
fillModuleSlot('uo.guild.detail', Feed)
offerCoreFill('team.activity', Feed)
applyCoreFills()
assert.equal(extensionFor('uo.guild.detail'), Own, 'first fill wins, as everywhere else')
})
@@ -150,21 +189,21 @@ test('a module-declared slot cannot be declared twice', () => {
})
test('applying the fills twice does not re-fill or throw', () => {
declareModuleSlot('uo', 'uo.guild.detail')
fillModuleSlot('uo.guild.detail', Feed)
declareModuleSlot('uo', 'uo.guild.detail', { core: 'team.activity' })
offerCoreFill('team.activity', Feed)
applyCoreFills()
assert.doesNotThrow(() => applyCoreFills())
assert.equal(extensionFor('uo.guild.detail'), Feed)
})
test('a non-component fill is refused at the call site, not at render', () => {
assert.throws(() => fillModuleSlot('uo.guild.detail', 'nope'), /is not a component/)
test('a non-component contribution is refused at the call site, not at render', () => {
assert.throws(() => offerCoreFill('team.activity', 'nope'), /is not a component/)
})
test('_reset clears pending fills, so one test cannot leak into the next', () => {
fillModuleSlot('uo.guild.detail', Feed)
offerCoreFill('team.activity', Feed)
_reset()
declareModuleSlot('uo', 'uo.guild.detail')
declareModuleSlot('uo', 'uo.guild.detail', { core: 'team.activity' })
applyCoreFills()
assert.equal(extensionFor('uo.guild.detail'), null)
})