From 2b74db670efbbbe7e84b763e263ca4f3b6a52c4e Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:06:08 +0800 Subject: [PATCH] refactor(client): rename the provide reprojection to updateCurrentProvideInfo and privatize the id resolvers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit provideInfo(id)/maybeProvideInfo(id) lost their last external caller when the renderer host switched to the currentProvideInfo observable; both become private (tests assert through the public projection). The reprojection method's name now says what it does — re-derive and publish on change — and matches the field family it maintains. --- packages/client/runtime/README.md | 2 +- .../runtime/src/client/sessions/service.ts | 23 ++++++------- .../runtime/tests/sessions-service.spec.ts | 32 +++++++++++-------- 3 files changed, 29 insertions(+), 28 deletions(-) diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 81261945cb..16c1124ec8 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -39,5 +39,5 @@ Changing the target can change or invalidate provider-side cache reuse; this pac ## Known Limitations and Deferred Work - **`loader.unload` is a stub (throws not-implemented)** — the full chain (fiber dispose → registration cascade → style removal) lands with the HMR project. -- **Scope teardown is stage-driven, single-occupant today** — the staged session follows `list.current` exactly (staging is the open signal: the event window opens ⟺ the session is on stage); a removed-while-staged session's scope survives frozen until the stage moves on, not until true observer count reaches zero. Resolution (`provideInfo()`/`binding()`/`scope()`) is pure addressing, render-safe. The staged state can widen to a multi-pane list when concurrent panes land. +- **Scope teardown is stage-driven, single-occupant today** — the staged session follows `list.current` exactly (staging is the open signal: the event window opens ⟺ the session is on stage); a removed-while-staged session's scope survives frozen until the stage moves on, not until true observer count reaches zero. Resolution (`binding()`/`scope()`) is pure addressing, render-safe; the render layer reads the current bundle through the `currentProvideInfo` observable. The staged state can widen to a multi-pane list when concurrent panes land. - **Value imports of this package from plugin bundles must use the `/client` subpath** — the bare package name is not in the loader externals table and inlines a second module instance, whose private scope-tag Symbol never matches (the empty-state P0 postmortem). diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index b5ce1905eb..759c320bde 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -212,7 +212,7 @@ export class SessionsService { // The current-provide projection follows the same current writes. this.list.subscribe(() => { this.followCurrent() - this.projectCurrentProvide() + this.updateCurrentProvideInfo() }) // The runtime's own contribution comes first: useSession rides the same // provide channel every plugin uses (no renderer special case). @@ -261,16 +261,17 @@ export class SessionsService { for (const record of this.scopes.values()) { record.provideInfo = this.materializeProvideInfo(record.binding) } - this.projectCurrentProvide() + this.updateCurrentProvideInfo() } /** - * Publish the current selection's provide bundle when it changed. Bundles - * are identity-stable per (scope, roster) materialization, so an identity - * compare is exact; synchronous notify — both call sites (list.subscribe, - * provide()) already sit behind their own batching or registration edges. + * Re-derive the current selection's provide bundle and publish it when it + * changed. Bundles are identity-stable per (scope, roster) + * materialization, so an identity compare is exact; synchronous notify — + * both call sites (list.subscribe, provide()) already sit behind their own + * batching or registration edges. */ - private projectCurrentProvide(): void { + private updateCurrentProvideInfo(): void { const next = this.maybeProvideInfo(this.list.getSnapshot().current) if (next === this.currentProvideInfoSnapshot) return this.currentProvideInfoSnapshot = next @@ -446,20 +447,16 @@ export class SessionsService { * {@link SessionsService.currentProvideInfo}). Pure resolution — render-safe: * no staging, no window side effects (StrictMode double-invokes and * concurrent discarded passes must stay free). - * @param id - session id. - * @returns the provide info, or undefined for a session neither listed nor already scoped. */ - provideInfo(id: string): SessionProvideInfo | undefined { + private provideInfo(id: string): SessionProvideInfo | undefined { return this.resolve(id as SessionId)?.provideInfo } /** * Resolve the current-session-optional standard kit. Unknown or absent ids * return the static no-session projection rather than removing hook props. - * @param id - current session id, when selected. - * @returns a definite or no-session provide bundle. */ - maybeProvideInfo(id: string | undefined): SessionMaybeProvideInfo { + private maybeProvideInfo(id: string | undefined): SessionMaybeProvideInfo { return (id === undefined ? undefined : this.provideInfo(id)) ?? this.maybeInfo } diff --git a/packages/client/runtime/tests/sessions-service.spec.ts b/packages/client/runtime/tests/sessions-service.spec.ts index b97dac3d78..45539d3b99 100644 --- a/packages/client/runtime/tests/sessions-service.spec.ts +++ b/packages/client/runtime/tests/sessions-service.spec.ts @@ -79,7 +79,8 @@ describe('scope tree', () => { expect(scopeOf(scoped as Context)).toBe('s1') expect(scopeOf(b.ctx)).toBeUndefined() const binding = b.svc.binding(sid('s1')) - expect(binding?.session).toBe(b.svc.provideInfo('s1')?.hooks['session']) + b.svc.open(sid('s1')) + expect(binding?.session).toBe(b.svc.currentProvideInfo.getSnapshot().hooks['session']) expect(b.svc.binding(sid('s1'))).toBe(binding) expect(binding?.ctx).toBe(scoped) }) @@ -183,16 +184,17 @@ describe('current selection (migrated from ui-layout, arbitrated into the list s }) describe('cell (render-layer session kit)', () => { - it('resolves an identity-stable {sessionId, session} cell; unknown ids yield undefined', async () => { + it('resolves an identity-stable {sessionId, session} cell through the current projection', async () => { const b = bench() await feedList(b, [{ id: 's1' }]) - const info = b.svc.provideInfo('s1') - expect(info).toBeDefined() - expect(info?.sessionId).toBe('s1') + b.svc.open(sid('s1')) + const info = b.svc.currentProvideInfo.getSnapshot() + expect(info.sessionId).toBe('s1') // The bundle carries bare observables; hook binding happens in React. - expect(info?.hooks['session']).toBe(b.svc.binding(sid('s1'))?.session) - expect(b.svc.provideInfo('s1')).toBe(info) - expect(b.svc.provideInfo('ghost')).toBeUndefined() + expect(info.hooks['session']).toBe(b.svc.binding(sid('s1'))?.session) + // Re-staging the same id republishes nothing: identity holds. + b.svc.open(sid('s1')) + expect(b.svc.currentProvideInfo.getSnapshot()).toBe(info) }) it('currentProvideInfo follows selection: absent projection ↔ definite bundle, notified on each move', async () => { @@ -204,10 +206,14 @@ describe('cell (render-layer session kit)', () => { const notified = vi.fn() b.svc.currentProvideInfo.subscribe(notified) b.svc.open(sid('s1')) - expect(b.svc.currentProvideInfo.getSnapshot()).toBe(b.svc.provideInfo('s1')) + const s1Bundle = b.svc.currentProvideInfo.getSnapshot() + expect(s1Bundle.sessionId).toBe('s1') + expect(s1Bundle.hooks['session']).toBe(b.svc.binding(sid('s1'))?.session) expect(notified).toHaveBeenCalledTimes(1) b.svc.open(sid('s2')) - expect(b.svc.currentProvideInfo.getSnapshot()).toBe(b.svc.provideInfo('s2')) + const s2Bundle = b.svc.currentProvideInfo.getSnapshot() + expect(s2Bundle.sessionId).toBe('s2') + expect(s2Bundle).not.toBe(s1Bundle) expect(notified).toHaveBeenCalledTimes(2) b.svc.clear() await Promise.resolve() // clearSelection projects through the manager notifier @@ -249,12 +255,11 @@ describe('cell (render-layer session kit)', () => { expect(notified).not.toHaveBeenCalled() }) - it('provideInfo()/binding() are pure resolution: no staging, no deferred sweep', async () => { + it('binding() is pure resolution: no staging, no deferred sweep', async () => { const b = bench() await feedList(b, [{ id: 's1' }, { id: 's2' }]) b.svc.open(sid('s1')) // staged - b.svc.provideInfo('s2') // resolution only — must NOT move the stage - b.svc.binding(sid('s2')) + b.svc.binding(sid('s2')) // resolution only — must NOT move the stage await feedList(b, [{ id: 's2' }]) // s1 removed: still staged → deferred, scope survives expect(b.svc.scope(sid('s1'))).toBeDefined() }) @@ -265,7 +270,6 @@ describe('cell (render-layer session kit)', () => { const historyCalls = () => b.api.calls.filter(c => c.method === 'session.history') // Resolution is addressing, not staging: no window pull. b.svc.scope(sid('s1')) - b.svc.provideInfo('s1') b.svc.binding(sid('s1')) expect(historyCalls()).toHaveLength(0) b.svc.open(sid('s1'))