From 002a3efde1f4b122498483769a0befdd3a1938a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 09:44:26 +0000 Subject: [PATCH 1/2] fix(core): a registration that declines the bare key no longer clears 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 Claude-Session: https://claude.ai/code/session_018HrVaotisyhgmot9o2MLRq --- ...39-register-skipfallback-preserves-stub.md | 15 ++ packages/core/src/registry/Registry.ts | 75 ++++++++-- .../src/registry/__tests__/Registry.test.ts | 134 ++++++++++++++++-- 3 files changed, 202 insertions(+), 22 deletions(-) create mode 100644 .changeset/9839-register-skipfallback-preserves-stub.md diff --git a/.changeset/9839-register-skipfallback-preserves-stub.md b/.changeset/9839-register-skipfallback-preserves-stub.md new file mode 100644 index 0000000000..d5b2d04503 --- /dev/null +++ b/.changeset/9839-register-skipfallback-preserves-stub.md @@ -0,0 +1,15 @@ +--- +'@object-ui/core': patch +--- + +`Registry.register` with `skipFallback: true` no longer deletes another declaration's bare-name lazy stub, so an authored node under the bare name keeps rendering instead of resolving to nothing. + +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 user-visible symptom: a plugin declared lazily under a bare name — `registerLazy('dashboard', …, { namespace: 'plugin-dashboard' })` — and a second package registering the same base type under its own namespace *with the opt-out set*, `{ namespace: 'view', skipFallback: true }`. The opt-out exists precisely so the second registration does not take the bare key off the first. It did not take it; it deleted it anyway. The bare stub-clearing lines sat OUTSIDE the `namespace && !skipFallback` branch, so the branch that claims the bare key was skipped while the delete below it ran unconditionally. An authored `dashboard` node then resolved to nobody at all: the stub that would have loaded the chunk was gone, and the registration that removed it had declined to replace it. The registry reported no collision, because from its point of view no one was contesting anything. + +The clearing now sits inside that branch, under the same predicate that takes the key: a registration clears the bare stub when — and only when — it claims the bare key. A registration with no namespace still clears it, because its full type IS the bare key. The ordinary stub-then-real lifecycle is untouched: a registration that satisfies its own stub still clears both keys. + +`unregister` carried the identical unguarded pair and is repaired with it. Its docblock promised «the bare-name fallback (when the fallback still points at this registration), plus any matching lazy stub», and «matching» is ruled here from the code's own behaviour rather than from the phrase: the components half of that same sentence is already ownership-scoped and will not drop bare `dashboard` while it resolves to `plugin-dashboard:dashboard`. Reading the stub half 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 the "has the chunk loaded yet" race this class is about. The stub therefore gets the same ownership test. The bare form, `unregister(type)` with no namespace, stays unconditional on both tables: that is the force form teardown sites pair with the namespaced call. + +The eager door's cross-table collision warning changed with it, and had to. It used to talk the author OUT of `skipFallback: true` — correct while the delete ran either way, since the opt-out then left the bare key resolving to nothing. Now the opt-out settles that contest exactly as it does on the lazy door, so both doors prescribe it. The paragraph in the pending `registerLazy` cross-table collision entry that says the eager door cannot prescribe that opt-out describes the behaviour before this change. diff --git a/packages/core/src/registry/Registry.ts b/packages/core/src/registry/Registry.ts index 89417a7382..619834341a 100644 --- a/packages/core/src/registry/Registry.ts +++ b/packages/core/src/registry/Registry.ts @@ -179,7 +179,13 @@ export type RegistryComponentMetaExtras = { * When true, prevents the component from being registered with a non-namespaced fallback. * Use this when a component should only be accessible via its full namespaced key. * This avoids conflicts with other components that share the same base name. - * + * + * ⚠️ That promise covers BOTH registry tables since objectui#9839, and it did + * not before. Declining the bare key used to leave `register` deleting a bare + * lazy stub anyway, so a registration that opted out still took the bare key + * off its owner — and then refused to replace it, leaving it claimed by + * nobody. An opt-out now leaves the bare key exactly where it was. + * * @example * // Register as 'view:form' only, don't overwrite 'form' * registry.register('form', FormView, { namespace: 'view', skipFallback: true }); @@ -531,6 +537,16 @@ export class Registry { // in the order this repository does not boot in. The `report-` and // `timeline-bare-key-ownership` pins replay both orders for exactly // that reason. + // + // ⭐ The remedy this warning names CHANGED with objectui#9839, and it + // changed because the behaviour behind it did. It used to send the + // author away from `skipFallback: true` — correctly at the time, since + // this door cleared the bare stub outside the fallback branch, so the + // opt-out left the bare key resolving to nothing at all. The delete is + // now inside the branch, so declining the bare key leaves it with its + // owner and the opt-out settles this contest exactly as it does on the + // lazy door. Both doors therefore prescribe the same thing now, which + // `each door prescribes the remedy that is true for it` pins. const stub = this.lazyEntries.get(type); const stubType = stub ? lazyStubFullType(type, stub) : undefined; if (stubType && stubType !== fullType) { @@ -538,9 +554,10 @@ export class Registry { `Component "${type}" bare-name fallback is being overwritten by "${fullType}", ` + `which a pending lazy stub already claims for "${stubType}". Which one an ` + `authored "${type}" node resolves to depends on whether that chunk has loaded. ` + - `Make the two declarations name ONE full type — { skipFallback: true } does not ` + - `settle this one, because registering "${fullType}" clears the bare "${type}" ` + - `stub either way.`, + `If this is intentional keep going; otherwise register "${fullType}" with ` + + `{ skipFallback: true } so it doesn't claim the bare "${type}" key — that ` + + `opt-out settles it, because the bare "${type}" key is then left with ` + + `"${stubType}".`, ); } } @@ -549,21 +566,53 @@ export class Registry { component, ...resolvedMeta }); + // The bare key now resolves to THIS registration, so the bare stub is + // ours to clear. It is cleared here — inside the branch, under the same + // predicate that took the key — and deliberately not below (objectui#9839). + this.lazyEntries.delete(type); } - // A real component is now available — clear any matching lazy stub so we - // don't keep holding the loader reference, and notify subscribers. + // A real component is now available — clear the stub for the key this + // registration actually claimed, so we don't keep holding the loader + // reference, and notify subscribers. + // + // ⭐ `fullType` only. With no namespace `fullType === type`, so a bare + // registration still clears the bare stub — it claimed the bare key. What + // this line must NOT do is clear a bare stub the registration DECLINED: + // `skipFallback: true` skips the branch above, so `components` is never + // written under the bare key, and deleting the stub here took the bare key + // away from its owner and gave it to nobody — the stub was gone and this + // registration had refused to replace it (objectui#9839). A registration + // that opts out of the bare key opts out of clearing it too. this.lazyEntries.delete(fullType); - this.lazyEntries.delete(type); this.notify(); } /** * Remove a previously registered component. Mirrors {@link register} by * clearing both the namespaced key and the bare-name fallback (when the - * fallback still points at this registration), plus any matching lazy stub. + * fallback still points at this registration), plus any matching lazy stub — + * where "matching" means the same thing on both tables: a stub that still + * belongs to THIS registration (objectui#9839). * Notifies subscribers only when something was actually removed. * + * ⭐ Why "matching" cannot mean "a stub under either key". This function + * already answers that question for the bare `components` key and answers it + * "ownership" — it will not drop bare `dashboard` when it resolves to + * `plugin-dashboard:dashboard`. Reading the next line as an unconditional + * delete makes the SAME call, on the SAME bare key, with the SAME owner, + * take the opposite decision 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, so the stub gets the ownership test too. + * + * ⚠️ The BARE form (`unregister(type)` with no namespace) is unchanged and + * is still the unconditional one, on both tables: `fullType === type` there, + * so the deletes below address the bare key directly. That is the "force" + * form teardown sites pair with the namespaced call, and taking it away + * would break them. + * * Mainly used by tests that install a stub renderer and need to restore the * prior registry state on teardown, since the registry is a process-level * singleton shared across test files. @@ -577,7 +626,13 @@ export class Registry { if (bare && bare.type === fullType) this.components.delete(type); } this.lazyEntries.delete(fullType); - this.lazyEntries.delete(type); + // The same ownership test, on the other table. + if (namespace) { + const bareStub = this.lazyEntries.get(type); + if (bareStub && lazyStubFullType(type, bareStub) === fullType) { + this.lazyEntries.delete(type); + } + } if (removed) this.notify(); return removed; } @@ -637,7 +692,7 @@ export class Registry { `which ${loaded ? 'a loaded registration' : 'another pending stub'} already claims ` + `for "${claimedBy}". If this is intentional keep going; otherwise register ` + `"${fullType}" with { skipFallback: true } so it doesn't claim the bare "${type}" ` + - `key — on this door that opt-out does settle it, because the stub then claims only ` + + `key — that opt-out settles it, because the stub then claims only ` + `"${fullType}".`, ); } diff --git a/packages/core/src/registry/__tests__/Registry.test.ts b/packages/core/src/registry/__tests__/Registry.test.ts index 4867f2c154..70076621bd 100644 --- a/packages/core/src/registry/__tests__/Registry.test.ts +++ b/packages/core/src/registry/__tests__/Registry.test.ts @@ -557,24 +557,86 @@ describe('Registry', () => { }); /** - * ⭐ Why the two doors give DIFFERENT advice, pinned as behaviour and not - * only as prose. `register` clears the bare stub unconditionally — outside - * the fallback branch — so telling that door's author to pass - * `skipFallback: true` would leave the bare key resolving to nothing at - * all. On the lazy door the same opt-out really does settle it. + * ⭐ This pin was INVERTED by objectui#9839, and the inversion is the fix. + * + * It used to assert that the eager door left the bare key claimed by + * NOBODY: `register` cleared the bare stub outside its + * `namespace && !skipFallback` branch, so a registration that declined the + * bare key deleted another declaration's stub anyway and then refused to + * replace it. The comment it carried said so in as many words, which is + * what made it a pin on a defect rather than a blessing of one. + * + * The clearing now sits inside that branch, under the same predicate that + * takes the key, so declining the key declines the delete with it. */ - it('skipFallback on the EAGER door does not preserve the stub\'s bare claim', () => { + it('skipFallback on the EAGER door preserves the stub\'s bare claim', () => { registry.registerLazy('dashboard', loader, { namespace: 'plugin-dashboard' }); expect(registry.hasLazy('dashboard')).toBe(true); registry.register('dashboard', () => 'view', { namespace: 'view', skipFallback: true }); - // The bare key is now claimed by nobody: the stub was deleted anyway and - // the registration declined to take it. - expect(registry.hasLazy('dashboard')).toBe(false); + // The bare key is still the stub's: this registration declined to take + // it, so it also declined to clear it. `view:dashboard` is reachable by + // its full key only, which is the whole point of the opt-out. + expect(registry.hasLazy('dashboard')).toBe(true); + expect(registry.hasLazy('dashboard', 'plugin-dashboard')).toBe(true); + expect(registry.get('dashboard', 'view')).toBeDefined(); + // ⛔ Not `toBeDefined()` on the bare read: the stub has not loaded, so + // bare `dashboard` resolves to nothing YET — but it is spoken for, and + // `hasLazy` above is what says by whom. Before the fix it was spoken for + // by nobody at all, and those two states read identically through `get`. expect(registry.get('dashboard')).toBeUndefined(); }); + /** + * The opposite direction, which the repair must NOT disturb: a + * registration that DOES claim the bare key still clears the stub under it. + * Leaving that stub behind would be the converse defect — objectui#9533's + * shape, a stub that outlives the registration that satisfied it. + */ + it('a registration that DOES claim the bare key still clears the stub', () => { + registry.registerLazy('dashboard', loader, { namespace: 'plugin-dashboard' }); + expect(registry.hasLazy('dashboard')).toBe(true); + + registry.register('dashboard', () => 'view', { namespace: 'view' }); + + // The bare stub is gone because this registration took the bare key — + // bare `dashboard` resolves to `view:dashboard` now, and a stub left + // sitting under it would be unsatisfiable. (It warned on the way past; + // that contest is the block above's subject, not this one's.) + expect(registry.hasLazy('dashboard')).toBe(false); + expect(registry.get('dashboard')).toBeDefined(); + // ⛔ `plugin-dashboard:dashboard` is a DIFFERENT key and this call never + // addressed it — only the bare key was contested. + expect(registry.hasLazy('dashboard', 'plugin-dashboard')).toBe(true); + }); + + it('the ordinary stub-then-real lifecycle still clears BOTH stub keys', () => { + // The 30-of-31 shape: the stub and the registration that satisfies it + // name one full type, so both the bare and the namespaced stub key are + // this registration's to clear. + registry.registerLazy('chart', loader, { namespace: 'plugin-charts' }); + expect(registry.hasLazy('chart')).toBe(true); + expect(registry.hasLazy('chart', 'plugin-charts')).toBe(true); + + registry.register('chart', () => 'chart', { namespace: 'plugin-charts' }); + + expect(registry.hasLazy('chart')).toBe(false); + expect(registry.hasLazy('chart', 'plugin-charts')).toBe(false); + expect(registry.get('chart')).toBeDefined(); + }); + + it('a bare registration with no namespace still clears the bare stub', () => { + // `fullType === type` here, so the unconditional delete below the branch + // is the one that does it. A registration with no namespace claims the + // bare key by definition, so it clears the bare key's stub. + registry.registerLazy('dashboard', loader, { namespace: 'plugin-dashboard' }); + registry.register('dashboard', () => 'plain'); + + expect(registry.hasLazy('dashboard')).toBe(false); + expect(registry.get('dashboard')).toBeDefined(); + }); + it('each door prescribes the remedy that is true for it', () => { registry.register('dashboard', () => 'view', { namespace: 'view' }); consoleWarnSpy.mockClear(); @@ -585,9 +647,57 @@ describe('Registry', () => { consoleWarnSpy.mockClear(); other.registerLazy('dashboard', loader, { namespace: 'plugin-dashboard' }); other.register('dashboard', () => 'view', { namespace: 'view' }); - // ⛔ Not a wording preference: the eager door must not prescribe an - // opt-out that the test above shows does not settle the contest. - expect(collisionWarnings()[0]).toMatch(/does not\s+settle this one/); + // ⛔ Not a wording preference, and the reason it now reads the same on + // both doors is that the BEHAVIOUR converged (objectui#9839): the eager + // door used to have to talk the author out of `skipFallback: true`, + // because back then the opt-out left the bare key resolving to nothing. + // The probe above is what says that is no longer so. + expect(collisionWarnings()[0]).toMatch(/skipFallback: true/); + expect(collisionWarnings()[0]).not.toMatch(/does not\s+settle/); + }); + + /** + * ⭐ `unregister`'s docblock promised «the bare-name fallback (when the + * fallback still points at this registration), plus any matching lazy + * stub». objectui#9839 ruled «matching» against the code's own behaviour: + * the components half of that same sentence is ownership-scoped, so the + * stub half is too. Without this, the repair above would just move the + * victim one function down — the same bare key, the same owner, taken by + * the same caller. + */ + it('unregister leaves a bare stub that belongs to a DIFFERENT full type', () => { + registry.registerLazy('dashboard', loader, { namespace: 'plugin-dashboard' }); + registry.register('dashboard', () => 'view', { namespace: 'view', skipFallback: true }); + + expect(registry.unregister('dashboard', 'view')).toBe(true); + + // `view:dashboard` is gone; the bare key is still the stub's. + expect(registry.get('dashboard', 'view')).toBeUndefined(); + expect(registry.hasLazy('dashboard')).toBe(true); + expect(registry.hasLazy('dashboard', 'plugin-dashboard')).toBe(true); + }); + + it('unregister still clears a bare stub that DOES belong to it', () => { + registry.registerLazy('dashboard', loader, { namespace: 'plugin-dashboard' }); + registry.register('dashboard', () => 'dash', { namespace: 'plugin-dashboard' }); + + expect(registry.unregister('dashboard', 'plugin-dashboard')).toBe(true); + expect(registry.hasLazy('dashboard')).toBe(false); + expect(registry.hasLazy('dashboard', 'plugin-dashboard')).toBe(false); + expect(registry.get('dashboard')).toBeUndefined(); + }); + + it('the BARE unregister form stays unconditional on both tables', () => { + // Teardown sites pair `unregister(type, ns)` with `unregister(type)` to + // force the bare key regardless of who holds it. That second call must + // keep working, on `lazyEntries` as well as on `components`. + registry.registerLazy('dashboard', loader, { namespace: 'plugin-dashboard' }); + registry.unregister('dashboard'); + + expect(registry.hasLazy('dashboard')).toBe(false); + // ⛔ Only the bare key: the namespaced stub is a different key and this + // call never addressed it. + expect(registry.hasLazy('dashboard', 'plugin-dashboard')).toBe(true); }); }); }); From 58ad990fe96551084f9fd8391999d0dc62ed4e48 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 10:12:35 +0000 Subject: [PATCH 2/2] docs(changeset): the eager door's remedy sentence, corrected in both 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 Claude-Session: https://claude.ai/code/session_018HrVaotisyhgmot9o2MLRq --- .changeset/9821-registerlazy-cross-table-collision-warning.md | 2 +- .changeset/9839-register-skipfallback-preserves-stub.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/9821-registerlazy-cross-table-collision-warning.md b/.changeset/9821-registerlazy-cross-table-collision-warning.md index 23dee0c7bc..58b503f591 100644 --- a/.changeset/9821-registerlazy-cross-table-collision-warning.md +++ b/.changeset/9821-registerlazy-cross-table-collision-warning.md @@ -10,4 +10,4 @@ The registry has two doors onto one bare key: `register` writes `components`, `r Both doors now consult both tables and key on the declared full type, so a contest is reported whichever order the declarations arrive in. The ordinary stub-then-real lifecycle stays silent: a stub and the registration that satisfies it name one full type, and so do the bare keys this repository stubs twice from two files with different loader closures. -The two doors give different advice on purpose. `registerLazy` names `skipFallback: true`, which settles it there. `register` does not, because it clears the bare stub whether or not the fallback is taken, so that opt-out would leave the bare key resolving to nothing; it asks for the two declarations to agree on one full type instead. +The two doors give different advice on purpose. `registerLazy` names `skipFallback: true`, which settles it there. `register` did not, because it cleared the bare stub whether or not the fallback was taken, so that opt-out would have left the bare key resolving to nothing — until objectui#9839, in this same release, moved that clear inside the fallback branch, after which declining the bare key leaves it with its owner and both doors prescribe the opt-out, which also supersedes the sentence this paragraph opens with. diff --git a/.changeset/9839-register-skipfallback-preserves-stub.md b/.changeset/9839-register-skipfallback-preserves-stub.md index d5b2d04503..8a5848764f 100644 --- a/.changeset/9839-register-skipfallback-preserves-stub.md +++ b/.changeset/9839-register-skipfallback-preserves-stub.md @@ -12,4 +12,4 @@ The clearing now sits inside that branch, under the same predicate that takes th `unregister` carried the identical unguarded pair and is repaired with it. Its docblock promised «the bare-name fallback (when the fallback still points at this registration), plus any matching lazy stub», and «matching» is ruled here from the code's own behaviour rather than from the phrase: the components half of that same sentence is already ownership-scoped and will not drop bare `dashboard` while it resolves to `plugin-dashboard:dashboard`. Reading the stub half 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 the "has the chunk loaded yet" race this class is about. The stub therefore gets the same ownership test. The bare form, `unregister(type)` with no namespace, stays unconditional on both tables: that is the force form teardown sites pair with the namespaced call. -The eager door's cross-table collision warning changed with it, and had to. It used to talk the author OUT of `skipFallback: true` — correct while the delete ran either way, since the opt-out then left the bare key resolving to nothing. Now the opt-out settles that contest exactly as it does on the lazy door, so both doors prescribe it. The paragraph in the pending `registerLazy` cross-table collision entry that says the eager door cannot prescribe that opt-out describes the behaviour before this change. +The eager door's cross-table collision warning changed with it, and had to. It used to talk the author OUT of `skipFallback: true` — correct while the delete ran either way, since the opt-out then left the bare key resolving to nothing. Now the opt-out settles that contest exactly as it does on the lazy door, so both doors prescribe it. The pending `registerLazy` cross-table collision entry in this same release gave the old reason in its closing paragraph; that one sentence is corrected in place here, so the two entries do not publish contradicting accounts of what the opt-out does.