Repository navigation
fix(components,core): a protocol placeholder never takes a key a pending lazy stub owns (objectui#11680) - #11718
Merged
objectstack-fleet[bot] merged 6 commits intoOct 6, 2026
Conversation
…ing lazy stub owns (objectui#11680) `registerPlaceholder` guarded with `ComponentRegistry.get(type)`, which answers for loaded registrations only. The console declares `view:calendar` / `view:timeline` as lazy stubs and then calls `registerPlaceholders()`, so the placeholder took both keys, the registry cleared the stubs under them, and an authored node drew the dashed placeholder until something else loaded the chunk. The guard now also asks `hasLazy(type)`. The registry records a stub's full type on the entry when `registerLazy` declares it, instead of re-deriving it by prefixing the namespace onto the key a guard looked it up under. That re-derivation doubled the namespace for a stub found under its own full key (`view:view:calendar`) in the eager door's race warning, the lazy door's warning and `unregister`'s ownership test. Claude-Session: https://claude.ai/code/session_01FngvPpdrnhHMdHHq6vwwju Co-authored-by: Claude <noreply@anthropic.com>
…he placeholders register (objectui#11680) Claude-Session: https://claude.ai/code/session_01FngvPpdrnhHMdHHq6vwwju Co-authored-by: Claude <noreply@anthropic.com>
…g the schema prop (objectui#11680) `SchemaRenderer`'s `schema` prop takes declared node types only, so the pin declares `view:calendar` and `view:timeline` in `CustomNodeRegistry`, the way an application declares a type it registers. Claude-Session: https://claude.ai/code/session_01FngvPpdrnhHMdHHq6vwwju Co-authored-by: Claude <noreply@anthropic.com>
Contributor
✅ 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
|
…stub-guard Claude-Session: https://claude.ai/code/session_01FngvPpdrnhHMdHHq6vwwju Co-authored-by: Claude <noreply@anthropic.com>
…jectui#11680) Replays main.tsx's boot over the real stub list (register-plugins) and the real registerPlaceholders(), so the next console stub on a protocol key is caught without a new card. Positive control: every stub declared before the placeholders is still held after them, view:calendar and view:timeline among them. Claude-Session: https://claude.ai/code/session_01FngvPpdrnhHMdHHq6vwwju Co-authored-by: Claude <noreply@anthropic.com>
…b answers hasLazy (objectui#11680) The suite replaces ComponentRegistry with a hand-built partial stub. The placeholder registrar that @object-ui/components runs at module load now asks hasLazy() first, so the stub lacked a method the code under test calls and the suite failed at import. The stub gains hasLazy, answering false (no stub is pending in this suite). The suite's assertions are unchanged. Claude-Session: https://claude.ai/code/session_01FngvPpdrnhHMdHHq6vwwju Co-authored-by: Claude <noreply@anthropic.com>
Contributor
✅ 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
|
objectstack-fleet
Bot
deleted the
claude/issue-11680-placeholder-lazy-stub-guard
branch
October 6, 2026 14:19
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #11680
Clause-②: no
What was wrong
The console declares
view:calendarandview:timelineasregisterLazystubs inapps/console/src/register-plugins.ts(registerLazy('calendar', …, { namespace: 'view' }), the same fortimeline), then callsregisterPlaceholders()frommain.tsx.registerPlaceholderguarded withComponentRegistry.get(type), which answers for LOADED registrations only. So both keys read as free while their stubs were pending. The placeholder registered asprotocol-placeholder:view:calendarand took the bare keyview:calendar, which is the stub's own full key. The registry's eager door then cleared the stub under that key, as it does for any key a registration takes. Until some other node loaded the calendar or timeline chunk, an authoredview:calendar/view:timelinedrew the dashed "Component Placeholder" box, and every boot logged the race warning once per key.The warning read
view:view:calendarbecauselazyStubFullTypere-derived a stub's claim as "the entry's namespace, prefixed onto the key it was looked up under".registerLazystores one entry under two keys, its full key and its bare key, so a lookup that landed on the full key doubled the namespace.The change
@object-ui/components,registerPlaceholder: returns early whenComponentRegistry.hasLazy(type)is true, before the existing loaded-registration check.hasLazyis already public, so the registry gains no API. The lineif (!ComponentRegistry.get(type))is kept verbatim becausecheck:registry-bare-namesre-reads that exact text (GUARDED_INDIRECT_SITESinscripts/check-registry-bare-name-collisions.mjs), so that script needs no edit.@object-ui/core,Registry: a lazy entry records itsfullTypeon the line inregisterLazythat computes the key it is stored under. The three readers now readentry.fullType: the eager door's cross-table warning, the lazy door's prior-stub warning, and the bare-stub ownership test inunregister.lazyStubFullTypeis removed: re-deriving the claim from the lookup key was the defect, and there is nothing left for it to compute.Published declarations are byte-identical:
packages/core/dist/registry/Registry.d.tsbuilt at the base (9ca3cac, Registry.ts swapped in under a trap and restored, blob == HEAD) and at head compare equal (cmpexit 0, sha256 prefix893dd7070a53757bboth).LazyEntryis not exported andlazyEntriesisprivate.registerPlaceholderis not exported, sopackages/components/dist/renderers/placeholders.d.tscarries no docblock this diff touched. The claim'sClause-②: noholds.Premise checks (the order's mechanism assumptions, measured at
9ca3cac)packages/components/src/registry/Registry.tsdoes not exist. The registry ispackages/core/src/registry/Registry.ts. Confirmed.get()ignores pending stubs: confirmed on a freshRegistrywith onlyregisterLazy('calendar', …, { namespace: 'view' }):get('view:calendar')undefined,has('view:calendar')false,hasLazy('view:calendar')true,getKnownTypes()includes it.register('view:calendar', PlaceholderRenderer, { namespace: 'protocol-placeholder' }). Its bare key is the stub's full key. The stub side is not doubled. The same doubling sat inregisterLazy's prior-stub claim and inunregister's ownership test, and both are pinned below.PALETTE_PLACEHOLDER_BLOCKS, the eager side effect, is not the producer for these keys. It holdsnav:menu,nav:breadcrumb,global:searchandai:suggestiononly. Theview:*placeholders come fromregisterPlaceholders(). So the pin replaysregisterPlaceholders().registerPlaceholders()has one non-test caller,apps/console/src/main.tsx.registerPlaceholderhas two call sites, both inplaceholders.tsx: thePROTOCOL_COMPONENTSloop and the palette side effect. Lazy registrations whose full type isview:*: two,timelineandcalendar, both inregister-plugins.ts. Re-derive withgit grep -nE -A2 'registerLazy\(' -- 'apps/*/src/**' 'packages/*/src/**' | grep "namespace: 'view'". No host depends on the placeholder winning a stubbed key: nothing callsregisterPlaceholders()anywhere else, and no test asserts aprotocol-placeholder:view:*key.One-time full-load measurement (a scratch probe, deleted before commit, not a live pin)
The probe imported the console's real
register-plugins.ts, called the realregisterPlaceholders(), thenloadLazyon both keys (the real@object-ui/plugin-calendar/plugin-timelinechunks), countingbare-name fallbackwarnings:9ca3cacview:calendar,view:timeline, both namingview:view:…)view:calendarresolves toprotocol-placeholder:view:calendar;hasLazyfalsehasLazytrueview:calendarresolves to "Calendar View",view:timelineto "Timeline View"; 0 warningsPins
apps/console/src/__tests__/full-load-placeholder-race-11680.test.ts(console project; patch round 1): the live full-load pin. It replays the bootmain.tsxperforms over the console's REAL stub list,../register-plugins, and then calls the realregisterPlaceholders(). It asserts that the whole load emits no bare-name race warning. The capture is installed invi.hoisted, ahead of the imports, because the stubs register whileregister-pluginsis imported. Positive control, in the same file: every key a stub held before the placeholders registered is still held after them (hasLazy), and that set includesview:calendarandview:timeline, so the silence covers the two keys the card measured. Because it reads the real list, the next console stub that a placeholder races turns this pin red, with no new card. No console source is edited.packages/components/src/__tests__/placeholder-lazy-stub-ownership-11680.test.tsx(domproject, isolated): stubs declared with the console's arguments, each chunk gated so it lands on command and registers the way the plugin does. Uses the realregisterPlaceholders()and the realSchemaRenderer. Console boot order, forview:calendarandview:timeline: no race warning when the placeholders register or when the chunk lands. Before the load,get()is notPlaceholderRendererandhasLazy()is true, and an authored node never shows "Component Placeholder" and renders the plugin once the chunk lands. After the load,get()is the plugin's renderer. Controls: chunk-loaded-first order (unchanged behaviour), and a protocol key nothing owns (view:kanban) still gets the placeholder. The two node types are declared throughCustomNodeRegistry, the way an application declares a type it registers, rather than cast.packages/core/src/registry/__tests__/Registry.test.ts, blocka stub found under its FULL key names that full type once (objectui#11680): the eager door names the stubview:calendar, with noview:view:. The eager door still reports a registration whose own full type is the doubled spelling: before, the doubled claim equalled it, so the guard said nothing. The lazy door names the prior stubview:calendar.unregister('view:calendar', 'view')leaves the console's stub: before, it deleted it.packages/plugin-report/src/__tests__/ReportRenderer.test.tsx(patch round 1, the CI red at409496f): the suite replacesComponentRegistrywith a hand-built partial stub,{ get, register }. The palette placeholder side effect, which@object-ui/componentsruns at module load, now callshasLazy()first, so the suite failed at import withTypeError: ComponentRegistry.hasLazy is not a function(Test (shard 6/8)). The stub gainshasLazy, avi.fnthat answersfalse, because no stub is pending in that suite. Its assertions are unchanged, and the registrar does not tolerate a missing method. It is the onlyvi.mock('@object-ui/core')in the tree that hand-buildsComponentRegistry: the other two spread the real module. Red reproduced locally (the placeholder and suite bytes atbdcd1a9equal those at409496f):TypeError: ComponentRegistry.hasLazy is not a function,Test Files 1 failed (1). Green after:Tests 3 passed (3).Ablation and reverse verification (one-shot, all from committed state)
if (ComponentRegistry.hasLazy(type)) return;deleted throughablation-replace.mjs: anchor 1 → 0, blob202f3062ae59→e8a66e1276cb). Predicted: both console-boot rows red, the three controls green. Observed at409496f:Tests 2 failed | 3 passed (5), both failing onregistering the placeholders raced the stub: expected [ Array(1) ] to deeply equal []. Restore proven: blob == HEAD (202f3062ae59),git diff HEADempty.bdcd1a9:Tests 1 failed (1)ona registration took a bare key another declaration holds: expected [ …(2) ] to deeply equal [], the two captured warnings being theview:calendarandview:timelineraces, each now naming the stub's claim once (claims for "view:calendar"). Restore proven: blob == HEAD (202f3062ae59),git diff HEADempty.Registry.tsreplaced by its9ca3cacblob69457e1f5bb2under an EXIT/INT/TERM trap with an absolute path). Predicted: the four new Registry pins red, the rest green. Observed at18b8a39:Tests 4 failed | 55 passed (59): claimant'view:view:calendar'twice,expected [] to have a length of 1for the silent guard, andunregisterdeleting the stub. Restore proven: blob == HEAD (c7d525e29bc9),git diff HEADempty.@object-ui/core,@object-ui/componentsand@object-ui/reacttosrc, so no leg needed a rebuild ofdist.Gates
The branch merged
origin/mainat89cc738(mergebd6196d, no conflicts; that merge brings@object-ui/typesand@object-ui/app-shellchanges this diff does not touch). Head14498a5unless noted.pnpm --workspace-concurrency=2 --filter '@object-ui/console^...' build(the console's dependency closure, 35 of 47 workspace projects; needed because the console tsconfig resolves@object-ui/*through builtdist)bdcd1a9pnpm --filter @object-ui/console type-check(tsc --noEmit && tsc -b tsconfig.node.json --force;--listFilesOnlyshows the program reads the new pin)bdcd1a9pnpm --filter @object-ui/console lintbdcd1a9pnpm --filter @object-ui/plugin-report type-check·lint·pnpm exec vitest run packages/plugin-report/(26 files, 239 passed, 7 skipped)14498a5pnpm exec vitest runover the 20 console suites that importregister-plugins(the new pin among them), plus the components pin,Registry.test.tsandpalette-placeholder-blocksbdcd1a9pnpm exec vitest runover the console full-load pin,ReportRenderer.test.tsx, the components pin andRegistry.test.ts14498a5pnpm check:vi-mock-specifiers·check:vi-mock-inherit·check:vi-mock-override-shape14498a5pnpm check:new-line-citations·check:control-bytes·check:test-path-roots14498a5pnpm check:registry-bare-names·check:changeset-claims·changeset:check·node scripts/check-changeset-presence.mjs14498a5node scripts/check-governed-queue-guard.mjs --testover every changed path14498a5(round-1 paths),409496f(round-0 paths)pnpm --filter '@object-ui/components...' build;@object-ui/core+@object-ui/componentstype-checkandlint;vitest run packages/core/(197 files, 3915 passed) andpackages/components/(361 files, 3679 passed); the 22 round-0 registration and script pins (548 passed);check:doc-types·check:prompt-keys·check:pending-changeset-literals409496f(round 0; the build at18b8a39)Declared to CI, not run locally: the full
pnpm testshards, repo-widepnpm lint, and the remaining workflows. CI at409496fwas red onTest (shard 6/8)and the aggregateTestonly, which is theReportRenderersuite above.Changeset:
.changeset/11680-placeholder-lazy-stub-guard.md,patchfor@object-ui/componentsand@object-ui/core.Acceptance notes (observations, not filed)
@object-ui/componentsimport, before any host stub, but none of its four keys is stubbed anywhere.registerPlaceholders()'s one caller runs after the console's stubs. No producer, so noted only. Carrier: none.register's fallback branch removes that stub's canonical entry, not a bare one. That is how the placeholder erasedview:calendar's stub. With the placeholder fixed, no registration here has a colon-shaped bare key that equals another stub's full type, so the shape is dormant. Carrier: none.getContractConfigstill re-derives a stub's canonical type with its ownstartsWithtest instead of readingentry.fullType. It is the same class, and it agrees withentry.fullTypefor every stub this tree declares. It would diverge only forregisterLazy('ns:x', …, { namespace: 'ns' }), which nothing declares. Left alone, as polish outside the claim's surface. Carrier: none.apps/console/src/__tests__/full-load-placeholder-race-11680.test.ts(see Pins). The one-time measurement in the table above remains as the before/after reading on the real chunks.view:calendar/view:timelineare notDeclaredNodetypes in@object-ui/types, so the pin declares them throughCustomNodeRegistry. Observation only.Session for this implementation:
https://claude.ai/code/session_01FngvPpdrnhHMdHHq6vwwju(dispatched os-dev under the domain:ui seat 2 PM).Generated by Claude Code