feat(client): inject slot declaration lifetimes

This commit is contained in:
imccyu
2026-08-05 23:38:29 +08:00
parent bb53e25ed0
commit 86965b053c
100 changed files with 1065 additions and 837 deletions

View File

@@ -1,128 +0,0 @@
/**
* Declaration-aware registration deferral: the shared timing machinery for
* registering into a slot whose declaring entry activates in unconstrained
* order (dshClient.inject edges never sequence apply). Presence is judged on
* the LEDGER, not a local flag — after an HMR collapse re-declares the slot,
* the cascade has already removed the entry while the local disposer went
* stale, and a flag guard would block the re-registration.
*/
/** Minimal registry face the deferral reads (SlotsService satisfies it). */
export interface DeferralRegistry {
/** Declared spec lookup (undefined = not declared yet). */
spec(name: string): unknown
/** Current entries of the slot (component identity is the presence judge). */
entries(name: string): readonly { component: unknown }[]
/** Subscribe to the slot's ledger changes; returns the unsubscriber. */
subscribe(name: string, listener: () => void): () => void
}
/** Handle over one deferred registration. */
export interface DeferredRegistration {
/**
* Drop the current registration (stale disposers are harmless no-ops) and
* immediately re-attempt — the refresh path for registrants whose options
* carry localized text.
*/
refresh(): void
/** Unsubscribe and unregister (idempotent through the slot core). */
dispose(): void
}
/**
* Register into `name` as soon as its declaration is on the ledger, and
* re-register whenever the declaration reappears after a collapse.
* @param registry - the slot registry face.
* @param name - target slot name.
* @param component - the component whose ledger presence marks "registered".
* @param register - performs the actual registration; returns its disposer.
* @param onFailure - owns a registration failure that fires from a LATER
* ledger flush (a declaration landing after two providers deferred, say):
* the deferral first removes its own subscription, then hands the error
* over instead of throwing through the flush — the callback's chance to
* roll back sibling deferrals and surface the conflict on a loud channel.
* Absent, a late failure rethrows out of the flush.
* @returns the deferral handle (dispose in the owning effect's disposer).
* @throws the immediate registration's failure, after removing the
* just-installed subscription — a throwing construction leaves nothing live.
*/
export function deferRegistration(
registry: DeferralRegistry,
name: string,
component: unknown,
register: () => () => void,
onFailure?: (error: unknown) => void,
): DeferredRegistration {
let dispose: (() => void) | undefined
const tryRegister = (): void => {
if (registry.spec(name) === undefined) return
if (registry.entries(name).some(e => e.component === component)) return
dispose = register()
}
const unsubscribe = registry.subscribe(name, () => {
try {
tryRegister()
} catch (error) {
unsubscribe()
if (onFailure === undefined) throw error
onFailure(error)
}
})
try {
tryRegister()
} catch (error) {
// A synchronous registration failure (the declared slot is already
// occupied) must not leave the just-installed subscription behind: the
// caller receives no handle to dispose it through.
unsubscribe()
throw error
}
return {
refresh() {
dispose?.()
dispose = undefined
tryRegister()
},
dispose() {
unsubscribe()
dispose?.()
},
}
}
/**
* Defer ONE occupant into several holes as a unit. Construction that throws
* partway (a declared hole already occupied registers synchronously) rolls
* every earlier deferral back before rethrowing; a failure surfacing from a
* LATER ledger flush (holes declared after rival providers activated) rolls
* the whole group back the same way and re-raises the wrapped error on the
* global channel the boot's fail-loud handler owns — never a throw through
* the slot flush, never partial occupancy from the group's owner.
* @param registry - the slot registry face.
* @param names - the target holes (one registration per name).
* @param component - the occupant whose ledger presence marks "registered".
* @param register - performs one hole's registration; returns its disposer.
* @returns the group handle (dispose in the owning effect's disposer).
* @throws the immediate registration's failure, after rolling the group back.
*/
export function deferGroupRegistration<K extends string>(
registry: DeferralRegistry,
names: readonly K[],
component: unknown,
register: (name: K) => () => void,
): { dispose: () => void } {
const deferred: DeferredRegistration[] = []
const lateFailure = (error: unknown): void => {
for (const entry of deferred) entry.dispose()
queueMicrotask(() => { throw error instanceof Error ? error : new Error(String(error)) })
}
try {
for (const name of names) {
deferred.push(deferRegistration(registry, name, component, () => register(name), lateFailure))
}
} catch (error) {
for (const entry of deferred) entry.dispose()
throw error
}
return { dispose: () => { for (const entry of deferred) entry.dispose() } }
}

View File

@@ -19,7 +19,6 @@ import type { BoundActions, HandleOf, PropsStore, SnapshotSelectorHook, StoreDec
export * from './store.ts'
export * from './renderer.ts'
export * from './deferred.ts'
/** Slot contract table. Owners extend via declaration merging; entries are {@link SlotEntryDef}. */
export interface SlotMap {}
@@ -457,9 +456,12 @@ interface SlotRecord {
spec: SlotSpec<SlotEntryDef> | undefined
/** Diagnostics: which slot's entry declared this key ('(built-in)' for root). */
declaredBy: string | undefined
/** Monotonic declaration lifetime, distinct from ordinary entry mutations. */
declarationEpoch: number
entries: readonly StoredEntry[]
version: number
listeners: Set<() => void>
declarationListeners: Set<() => void>
}
const NO_ENTRIES: readonly StoredEntry[] = Object.freeze([])
@@ -473,8 +475,10 @@ const NO_ENTRIES: readonly StoredEntry[] = Object.freeze([])
*
* Change propagation contract: versions bump and {@link SlotCore.onMutate}
* fires synchronously per mutation (registry state is consistent when they
* fire); {@link SlotCore.subscribe} notifications batch per microtask, so N
* same-tick mutations produce one notification per touched key.
* fire); {@link SlotCore.subscribeDeclaration} fires synchronously for each
* declaration lifetime boundary; {@link SlotCore.subscribe} notifications
* batch per microtask, so N same-tick mutations produce one notification per
* touched key.
*/
export class SlotCore {
private records = new Map<string, SlotRecord>()
@@ -491,6 +495,7 @@ export class SlotCore {
const root = this.record('root')
root.spec = { kind: 'single', scope: 'root' }
root.declaredBy = '(built-in)'
root.declarationEpoch = 1
}
/**
@@ -631,12 +636,22 @@ export class SlotCore {
rec.entries = next
this.markDirty(options.name, rec)
if (options.children) {
const declarations: [key: string, record: SlotRecord][] = []
for (const [childKey, childSpec] of Object.entries(options.children)) {
const childRec = this.record(childKey)
childRec.spec = childSpec
childRec.declaredBy = `an entry in "${options.name}"${options.registrant ? ` (${options.registrant})` : ''}`
childRec.declarationEpoch += 1
declarations.push([childKey, childRec])
}
// Synchronous listeners may register into or try to redeclare a sibling;
// publish only after the whole children table owns its declarations.
for (const [childKey, childRec] of declarations) {
this.markDirty(childKey, childRec)
}
for (const [, childRec] of declarations) {
this.notifyDeclaration(childRec)
}
}
return () => {
if (!rec.entries.includes(entry)) return
@@ -692,6 +707,16 @@ export class SlotCore {
return this.records.get(key)?.spec
}
/**
* Read the declaration lifetime of a key. Entry additions and removals do
* not change it; declaration creation and collapse each advance it.
* @param key - slot key.
* @returns monotonic epoch (0 before the first declaration).
*/
declarationEpoch(key: string): number {
return this.records.get(key)?.declarationEpoch ?? 0
}
/**
* Subscribe to registration changes for a key (microtask-batched).
* Subscribing ahead of declaration is allowed; the declaration notifies.
@@ -705,6 +730,22 @@ export class SlotCore {
return () => { rec.listeners.delete(fn) }
}
/**
* Subscribe to declaration lifetime boundaries for a key. Notifications
* are synchronous so declaration teardown finishes before a subsequent
* same-tick registration can observe stale resources. Ordinary entry
* mutations do not notify this surface. A children table commits every
* sibling declaration before its first notification.
* @param key - slot key.
* @param fn - declaration or collapse callback.
* @returns unsubscribe.
*/
subscribeDeclaration(key: string, fn: () => void): () => void {
const rec = this.record(key)
rec.declarationListeners.add(fn)
return () => { rec.declarationListeners.delete(fn) }
}
/**
* Monotonic version for a key, bumped synchronously per mutation so a
* uSES getSnapshot read is never stale when its batched notification lands.
@@ -746,8 +787,10 @@ export class SlotCore {
const doomed = childRec.entries
childRec.spec = undefined
childRec.declaredBy = undefined
childRec.declarationEpoch += 1
childRec.entries = NO_ENTRIES
this.markDirty(childKey, childRec)
this.notifyDeclaration(childRec)
for (const dead of doomed) this.releaseEntry(dead)
}
}
@@ -755,7 +798,15 @@ export class SlotCore {
private record(key: string): SlotRecord {
let rec = this.records.get(key)
if (!rec) {
rec = { spec: undefined, declaredBy: undefined, entries: NO_ENTRIES, version: 0, listeners: new Set() }
rec = {
spec: undefined,
declaredBy: undefined,
declarationEpoch: 0,
entries: NO_ENTRIES,
version: 0,
listeners: new Set(),
declarationListeners: new Set(),
}
this.records.set(key, rec)
}
return rec
@@ -771,6 +822,10 @@ export class SlotCore {
}
}
private notifyDeclaration(rec: SlotRecord): void {
for (const fn of [...rec.declarationListeners]) fn()
}
private flush(): void {
// Reset before iterating so a mutation from inside a listener re-schedules.
this.flushScheduled = false