Repository navigation
fix(core): a registration that declines the bare key no longer clears its stub - #9851
Conversation
… its stub `Registry.register`'s bare stub-clearing lines sat outside the `namespace && !skipFallback` branch, so a registration passing `skipFallback: true` skipped the bare-key claim and deleted another declaration's bare lazy stub anyway. The bare key ended up claimed by nobody: the stub that would have loaded the chunk was gone, and the registration that removed it had declined to replace it. The clearing now sits inside that branch, under the same predicate that takes the key. A registration with no namespace still clears the bare stub, because its full type IS the bare key. `unregister` carried the identical unguarded pair, one function down and against the same victim, and is repaired with it: its bare stub now gets the ownership test its bare `components` key already had. The bare `unregister(type)` form stays unconditional on both tables. The eager door's cross-table collision warning used to talk the author out of `skipFallback: true`, which was true only while the delete ran either way. It now prescribes the opt-out, as the lazy door already did. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018HrVaotisyhgmot9o2MLRq
|
changeset-claim-re-read
|
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
…entries Prose only, frontmatter untouched, one sentence per file. The pending objectui#9821 entry closed by saying `register` cannot prescribe `skipFallback: true` "because it clears the bare stub whether or not the fallback is taken". This branch removes exactly that reason, and that sentence publishes verbatim into `@object-ui/core`'s CHANGELOG. Its closing sentence now states what is true and names the change that made it so. This branch's own entry pointed at that paragraph as describing the old behaviour; with the paragraph corrected in place, it now says so. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018HrVaotisyhgmot9o2MLRq
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
Fixes #9839
Clause-②: no — no exported symbol is added, no key on a published payload moves, and no accept set is relaxed. Which registrations the registry ACCEPTS is unchanged; what changes is which bare key survives a registration that declined to take it.
The defect
Registry.register's bare stub-clearing lines sat OUTSIDE itsmeta?.namespace && !meta?.skipFallbackbranch. A registration that explicitly DECLINED the bare key therefore skipped the claim and deleted another declaration's bare lazy stub anyway — and the bare key ended up claimed by nobody at all: the stub that would have loaded the chunk was gone, and the registration that removed it had refused to replace it. Nothing warned, because from the registry's point of view no one was contesting anything.Line numbers, re-taken (the card's were taken before objectui#9838 landed)
Measured on a freshly fetched
origin/main6c3944c30at 2026-09-18T09:59:54Z.packages/core/src/registry/Registry.tsis byte-identical between that ref and this branch's base276d17459(git diff --quietexit 0), so both readings describe the same file.if (meta?.namespace && !meta?.skipFallback) {:492:505this.components.set(type, {— the bare-key claim, INSIDE:506:547:511:552this.lazyEntries.delete(fullType);:515:556this.lazyEntries.delete(type);— outside, unconditional:516:557unregister(...):530:571componentsownership guard inside it:534-:536:577:538-:539:579/:580:521-:523:561-:570, phrase on:564The repair
The bare stub-clearing now sits inside that branch, under the same predicate that takes the key — the predicate is shared, not duplicated.
this.lazyEntries.delete(fullType)stays below and unconditional, which is what keeps a no-namespace registration clearing the bare stub: its full type IS the bare key.⛔ It does not touch which registrations are accepted. No
componentswrite moved. Stop condition 1 not reached.The
unregisterphrase, ruledThe docblock promised "the bare-name fallback (when the fallback still points at this registration), plus any matching lazy stub". Ruled here as "a stub that belongs to this registration", from the code's own behaviour rather than from the wording:
unregisteralready answers that exact question for the barecomponentskey, and answers it "ownership" — it will not drop baredashboardwhile it resolves toplugin-dashboard:dashboard. Reading the stub line as unconditional makes the same call, on the same bare key, with the same owner, decide the opposite way purely because the owner happens to be sitting in the other table at that instant — and which table it is sitting in is exactly the "has the chunk loaded yet" race that objectui#9533 and objectui#9821 are about. A policy that flips on chunk-load timing is not a policy.⭐ It is also not hypothetical: with
registerrepaired andunregisterleft alone, the identical victim survives one function down —unregister('dashboard', 'view')after a declined registration still takes baredashboardoffplugin-dashboard. The pinunregister leaves a bare stub that belongs to a DIFFERENT full typeis that case.unregister(type)with no namespace, is deliberately unchanged and still unconditional on both tables. That is the force form teardown sites pair with the namespaced call (html-page-lazy-blocks.test.tsxcalls both), and thecomponentshalf has the same asymmetry. Pinned bythe BARE unregister form stays unconditional on both tables.⭐ The teaching sweep — triage's escalation clause asks about the SOURCE
Triage asked whether any doc or example teaches
skipFallback: trueas the way to avoid affecting another declaration, because "那句话正在制造受害者". It does — eight places. Swept atorigin/main6c3944c30, 2026-09-18T09:59:32Z:skills/objectui/guides/plugin-development.mdskipFallback: true, // Don't overwrite other 'form' registrations— the literal prescriptionskills/objectui/guides/plugin-development.mdskipFallback: truewhen multiple plugins register the same base type"skills/objectui/guides/plugin-development.mdskills/objectui/evals/plugin-development.jsonskipFallback: truepackages/core/src/registry/Registry.ts// Skip this if skipFallback is true to avoid overwriting other components— sitting on the very branch whose delete ran anywaypackages/core/src/registry/Registry.tsskipFallbackJSDoc: "This avoids conflicts with other components that share the same base name"packages/plugin-grid/README.mdcontent/docs/plugins/plugin-grid.mdx203gridis deliberately not ours.skipFallback: true... keeps this plugin from claiming it"packages/plugin-form/README.mdcontent/docs/plugins/plugin-form.mdx356line_itemsis left to whoever else claims it"Every one of those promised something the code did not deliver whenever the other declaration's claim was a pending lazy stub.⚠️ Whether that escalates the grade is triage's call, ⛔ not this PR's and ⛔ not the seat's. The fix is the same either way, and after it every sentence in that table is true. ⛔ None of those files is edited here — they are outside this card's declared file surface, and they no longer need editing for truth.
The one existing green test this changed, named
skipFallback on the EAGER door does not preserve the stub's bare claim. Everything else inpackages/corestayed green (155 files / 3292 tests). It is rewritten to assert the repair and renamed toskipFallback on the EAGER door preserves the stub's bare claim.each door prescribes the remedy that is true for it. The code change leaves it green while falsifying its premise. That pin asserts the eager door's collision warning saysskipFallback: true"does not settle this one, because registering ... clears the bare stub either way" — a reason this change removes. Its own comment says it is "⛔ Not a wording preference: the eager door must not prescribe an opt-out that the test above shows does not settle the contest", and "the test above" is the probe that now shows the opposite. So the warning text is repaired (a false console warning shipped by the PR that falsifies it is the worst option), and that pin inverts with it — both doors now prescribe the opt-out, and it asserts the stale disclaimer is gone.Tests, and proof they can fail
Run at repo root per AGENTS.md; heavy runs serialized through
../objectstack/scripts/pm/os-verify-lock.sh.pnpm exec vitest run packages/core/— 155 files / 3292 tests, exit 0pnpm test --shard=1/4— 805 passed, 1 skipped (806 files), exit 0, 801sregisterLazy), call.unregister(, or assert on the collision warning text.pnpm check:registry-bare-namesindependently reports the production blast radius: 425 registration claims, 237 bare keys claimed, 59 declined withskipFallback, 1 contested and already in its ledger.pnpm --filter @object-ui/core type-checkexit 0 (afterpnpm --filter '@object-ui/core^...' build; the changed test file is genuinely covered —tsc -p tsconfig.test.json --listFilesnames it)pnpm --filter @object-ui/core lintexit 0 — 258 files, 0 errorscheck:control-bytes,check:new-line-citations(0 new),check:registry-bare-names,check:doc-types,check:pending-changeset-literals,check:handler-key-reads,check:changeset-presence— all exit 0.check:sdui-registration-pinsexits 2 = prerequisite not met (wants a console build); NOT MEASURED locally, left to CI. Shards 2-4 left to CI.Ablation — four legs, each mutation proven on disk and each restore proven against HEAD
Run through
ablation-replace.mjs, which refuses a mutation whose anchor misses and asserts the blob moved; each leg restored withblob == HEADand an emptygit diff HEAD. Run from the committed fix, so the restore leg has a real restore point.lazyEntries.delete(type)back below the branch (the defect)skipFallback on the EAGER door preserves the stub's bare claim+unregister leaves a bare stub that belongs to a DIFFERENT full typeunregister's ownership guardunregister leaves a bare stub that belongs to a DIFFERENT full typeonly — the two bare-form pins stayed green, so the guard is precisely targeteda registration that DOES claim the bare key still clears the stub+the ordinary stub-then-real lifecycle still clears BOTH stub keyseach door prescribes the remedy that is true for itLeg 3 is the one that matters for the opposite-direction case the card asks for: it proves those assertions are not vacuously true. ⛔ No ablation artefact is left in the tree —
git statusis clean and every restore printed its blob equality.Seat note — the fence was amended, and this section replaces a now-false one
domain:ui#2execution seat (session_018HrVaotisyhgmot9o2MLRq), 2026-09-18T10:16Z. The dev writes a PR body once and ⛔ never patches it; the body is the seat's to correct, and this section is the correction. It previously said.changeset/9821-registerlazy-cross-table-collision-warning.mdwas deliberately NOT edited. That is no longer true, and leaving it would be the exact defect this round exists to close — a false sentence sitting where people read it.What changed and why. That pending changeset (from objectui#9838, same release) ended by saying the eager door cannot prescribe
skipFallback: true「because it clears the bare stub whether or not the fallback is taken」. This PR removes precisely that reason, so the sentence would have published false into@object-ui/core's CHANGELOG. ⭐ The dev flagged it and refused to widen its own surface, which is correct; the seat then amended the fence by exactly that one file and one sentence. It now puts the old reason in past tense, names objectui#9839 as what removed it, and states what is true: declining the bare key leaves it with its owner, and both doors prescribe the opt-out.packages/typesis untouched」 standing while the diff moved 62 lines there; this is the third). ⛔ Not a fourth.Gates on the final diff, measured before this note was written.
check-changeset-presenceexit 0 ·check-changeset-claimsexit 0 ·check-changeset-overwriteexit 0 in its default report-only mode (exit 1 only underOS_CHANGESET_OVERWRITE_ENFORCE=1). Its finding namesM .changeset/9821-…and printsdeclared at base: @object-ui/core: patch/declares now: @object-ui/core: patch⇒ the hazard that gate exists for — a colliding filename silently destroying a third party's release declaration — demonstrably did not happen, and its own header names this shape as the legitimate one.One residual, recorded rather than hidden. That paragraph still OPENS with 「The two doors give different advice on purpose.」, which this change also falsifies. ⛔ Left as is, by seat decision: the paragraph's closing clause explicitly names that it supersedes its own opening sentence, so the text carries its correction inline rather than elsewhere, and the opener is not actionable on its own — a reader deciding whether to use⚠️ Stated as a judgement call with its cost, not as a clean result: a reader who skims only the first line gets the stale impression.
skipFallbackreads on and gets the right answer.The three pending changesets
check-changeset-claimsflags for namingRegistry.tswere read as paragraphs and judged against the final diff, each with a same-command control:6067-component-meta-derive-from-canonical(the key list is byte-identical between base and head),componentinput-reexport-4972(noComponentInputline in the diff),default-children-retired-5051(defaultChildrenabsent; controlskipFallbackmatches 13×). ⛔ None falsified.Attribution, in prose so it survives a body rewrite: produced by the
domain:ui#2execution seat, sessionsession_018HrVaotisyhgmot9o2MLRq.🤖 Generated with Claude Code
https://claude.ai/code/session_018HrVaotisyhgmot9o2MLRq
Generated by Claude Code
Generated by Claude Code