Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
15 changes: 15 additions & 0 deletions .changeset/9839-register-skipfallback-preserves-stub.md
Original file line number Diff line number Diff line change
@@ -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 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.
75 changes: 65 additions & 10 deletions packages/core/src/registry/Registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down Expand Up @@ -531,16 +537,27 @@ export class Registry<T = any> {
// 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) {
console.warn(
`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}".`,
);
}
}
Expand All @@ -549,21 +566,53 @@ export class Registry<T = any> {
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.
Expand All @@ -577,7 +626,13 @@ export class Registry<T = any> {
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;
}
Expand Down Expand Up @@ -637,7 +692,7 @@ export class Registry<T = any> {
`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}".`,
);
}
Expand Down
Loading
Loading