From 657abce60c9eb4ef93d15bc16c14973c6500529c Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Mon, 24 Aug 2026 17:47:40 +0200 Subject: [PATCH 1/8] refactor(v4): let getInstance take a nullable element MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `getInstance()` read `el[INSTANCES]?.get(name)`, a body that already tolerated a missing element, behind an `el: Element` parameter that did not. Every caller holding a `querySelector()` result had to assert the element away with a `!` — 58 of them across the specs — to state something the function never needed. The parameter is now `Element | null`. "No element" and "no instance" are the same answer to the caller, and `undefined` says it for both. The return stays `T | undefined`: an absent element is a fact the caller may reasonably not know, an absent instance is one it must handle. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NnepYhqjPMcFCSb43PoHRM --- packages/v4/migration/Action/Action.spec.ts | 2 +- .../v4/migration/AnchorNav/AnchorNav.spec.ts | 6 +-- .../migration/AnchorNav/AnchorNavLink.spec.ts | 2 +- .../ClickOutside/ClickOutside.spec.ts | 2 +- packages/v4/migration/Data/DataBind.spec.ts | 2 +- .../v4/migration/Data/DataDerived.spec.ts | 2 +- packages/v4/migration/Data/DataScope.spec.ts | 2 +- .../v4/migration/Figure/FigureShopify.spec.ts | 2 +- .../migration/Figure/FigureTwicpics.spec.ts | 2 +- .../FigureVideo/FigureVideoTwicpics.spec.ts | 2 +- packages/v4/migration/Menu/Menu.spec.ts | 6 +-- packages/v4/migration/Menu/MenuList.spec.ts | 4 +- packages/v4/migration/Toaster/Toaster.spec.ts | 2 +- packages/v4/src/Base.spec.ts | 10 ++-- packages/v4/src/context-subscription.spec.ts | 4 +- packages/v4/src/context.spec.ts | 2 +- packages/v4/src/decorators.spec.ts | 14 +++--- packages/v4/src/group.spec.ts | 4 +- packages/v4/src/instances.ts | 19 +++++++- packages/v4/src/props.spec.ts | 2 +- packages/v4/src/responsive-options.spec.ts | 46 +++++++++---------- 21 files changed, 75 insertions(+), 62 deletions(-) diff --git a/packages/v4/migration/Action/Action.spec.ts b/packages/v4/migration/Action/Action.spec.ts index ab7e5e2b..fabe008a 100644 --- a/packages/v4/migration/Action/Action.spec.ts +++ b/packages/v4/migration/Action/Action.spec.ts @@ -43,7 +43,7 @@ registerComponents(Action, Target, Foo, Bar, Dialog, MountProbe); afterEach(resetDom); function at(root: ParentNode, selector: string, name: string): T { - return getInstance(root.querySelector(selector)!, name)!; + return getInstance(root.querySelector(selector), name)!; } function click(el: Element): Event { diff --git a/packages/v4/migration/AnchorNav/AnchorNav.spec.ts b/packages/v4/migration/AnchorNav/AnchorNav.spec.ts index dbf14bef..61e40606 100644 --- a/packages/v4/migration/AnchorNav/AnchorNav.spec.ts +++ b/packages/v4/migration/AnchorNav/AnchorNav.spec.ts @@ -25,7 +25,7 @@ describe('AnchorNav', () => { it('enters the matching link once its target scrolls into view', async () => { const { root, target } = await render(); const link = getInstance( - root.querySelector('[data-component="AnchorNavLink"]')!, + root.querySelector('[data-component="AnchorNavLink"]'), 'AnchorNavLink', )!; @@ -41,7 +41,7 @@ describe('AnchorNav', () => { it('leaves the matching link once its target scrolls back out of view', async () => { const { root, target } = await render(); const link = getInstance( - root.querySelector('[data-component="AnchorNavLink"]')!, + root.querySelector('[data-component="AnchorNavLink"]'), 'AnchorNavLink', )!; @@ -66,7 +66,7 @@ describe('AnchorNav', () => { document.body.append(root); await settle(); const link = getInstance( - root.querySelector('[data-component="AnchorNavLink"]')!, + root.querySelector('[data-component="AnchorNavLink"]'), 'AnchorNavLink', )!; const target = root.querySelector('#one') as HTMLElement; diff --git a/packages/v4/migration/AnchorNav/AnchorNavLink.spec.ts b/packages/v4/migration/AnchorNav/AnchorNavLink.spec.ts index fbfbee04..08a0f8cb 100644 --- a/packages/v4/migration/AnchorNav/AnchorNavLink.spec.ts +++ b/packages/v4/migration/AnchorNav/AnchorNavLink.spec.ts @@ -23,7 +23,7 @@ async function render(): Promise { root.innerHTML = ``; document.body.append(root); await settle(); - return getInstance(root.firstElementChild!, 'AnchorNavLink')!; + return getInstance(root.firstElementChild, 'AnchorNavLink')!; } describe('AnchorNavLink', () => { diff --git a/packages/v4/migration/ClickOutside/ClickOutside.spec.ts b/packages/v4/migration/ClickOutside/ClickOutside.spec.ts index 717867f6..8bb95eef 100644 --- a/packages/v4/migration/ClickOutside/ClickOutside.spec.ts +++ b/packages/v4/migration/ClickOutside/ClickOutside.spec.ts @@ -79,7 +79,7 @@ describe('ClickOutside', () => { outside.click(); const dropdown = getInstance( - root.querySelector('[data-component="Dropdown"]')!, + root.querySelector('[data-component="Dropdown"]'), 'Dropdown', )!; expect(dropdown.closed).toHaveLength(1); diff --git a/packages/v4/migration/Data/DataBind.spec.ts b/packages/v4/migration/Data/DataBind.spec.ts index 7b71708b..eb788bf9 100644 --- a/packages/v4/migration/Data/DataBind.spec.ts +++ b/packages/v4/migration/Data/DataBind.spec.ts @@ -67,7 +67,7 @@ function el(root: HTMLElement, selector: st } function at(root: HTMLElement, selector: string, name: string): T { - return getInstance(root.querySelector(selector)!, name)! as T; + return getInstance(root.querySelector(selector), name)! as T; } describe('DataBind — the element half', () => { diff --git a/packages/v4/migration/Data/DataDerived.spec.ts b/packages/v4/migration/Data/DataDerived.spec.ts index 3f95defc..8940d2ed 100644 --- a/packages/v4/migration/Data/DataDerived.spec.ts +++ b/packages/v4/migration/Data/DataDerived.spec.ts @@ -30,7 +30,7 @@ function el(root: HTMLElement, selector: st } function at(root: HTMLElement, selector: string, name: string): T { - return getInstance(root.querySelector(selector)!, name)! as T; + return getInstance(root.querySelector(selector), name)! as T; } describe('DataModel', () => { diff --git a/packages/v4/migration/Data/DataScope.spec.ts b/packages/v4/migration/Data/DataScope.spec.ts index a4e3ee1c..304d0ea5 100644 --- a/packages/v4/migration/Data/DataScope.spec.ts +++ b/packages/v4/migration/Data/DataScope.spec.ts @@ -27,7 +27,7 @@ function uniqueGroup(name: string): string { } function at(root: HTMLElement, selector: string, name: string): T { - return getInstance(root.querySelector(selector)!, name)! as T; + return getInstance(root.querySelector(selector), name)! as T; } function el(root: HTMLElement, selector: string): T { diff --git a/packages/v4/migration/Figure/FigureShopify.spec.ts b/packages/v4/migration/Figure/FigureShopify.spec.ts index 0d9766c0..9233ce42 100644 --- a/packages/v4/migration/Figure/FigureShopify.spec.ts +++ b/packages/v4/migration/Figure/FigureShopify.spec.ts @@ -19,7 +19,7 @@ async function render(attributes = ''): Promise { `; document.body.append(root); await settle(); - return getInstance(root.firstElementChild!, 'FigureShopify')!; + return getInstance(root.firstElementChild, 'FigureShopify')!; } describe('FigureShopify', () => { diff --git a/packages/v4/migration/Figure/FigureTwicpics.spec.ts b/packages/v4/migration/Figure/FigureTwicpics.spec.ts index c365a33b..ccdc9eca 100644 --- a/packages/v4/migration/Figure/FigureTwicpics.spec.ts +++ b/packages/v4/migration/Figure/FigureTwicpics.spec.ts @@ -20,7 +20,7 @@ async function render( `; document.body.append(root); await settle(); - return getInstance(root.firstElementChild!, 'FigureTwicpics')!; + return getInstance(root.firstElementChild, 'FigureTwicpics')!; } describe('FigureTwicpics', () => { diff --git a/packages/v4/migration/FigureVideo/FigureVideoTwicpics.spec.ts b/packages/v4/migration/FigureVideo/FigureVideoTwicpics.spec.ts index 060300f4..b564ee1e 100644 --- a/packages/v4/migration/FigureVideo/FigureVideoTwicpics.spec.ts +++ b/packages/v4/migration/FigureVideo/FigureVideoTwicpics.spec.ts @@ -20,7 +20,7 @@ async function render(attributes = ''): Promise { `; document.body.append(root); await settle(); - return getInstance(root.firstElementChild!, 'FigureVideoTwicpics')!; + return getInstance(root.firstElementChild, 'FigureVideoTwicpics')!; } describe('FigureVideoTwicpics — the loadSources override', () => { diff --git a/packages/v4/migration/Menu/Menu.spec.ts b/packages/v4/migration/Menu/Menu.spec.ts index 521e5424..c060fba1 100644 --- a/packages/v4/migration/Menu/Menu.spec.ts +++ b/packages/v4/migration/Menu/Menu.spec.ts @@ -28,7 +28,7 @@ async function render(mode?: string): Promise<{ root: HTMLElement; menu: Menu }> root.innerHTML = menuMarkup(mode); document.body.append(root); await settle(); - return { root, menu: getInstance(root.querySelector('[data-component="Menu"]')!, 'Menu')! }; + return { root, menu: getInstance(root.querySelector('[data-component="Menu"]'), 'Menu')! }; } describe('Menu', () => { @@ -133,8 +133,8 @@ describe('Menu', () => { `; document.body.append(root); await settle(); - const subA = getInstance(root.querySelector('#sub-a')!, 'Menu')!; - const subB = getInstance(root.querySelector('#sub-b')!, 'Menu')!; + const subA = getInstance(root.querySelector('#sub-a'), 'Menu')!; + const subB = getInstance(root.querySelector('#sub-b'), 'Menu')!; subA.open(); expect(subA.menuList?.isOpen).toBe(true); diff --git a/packages/v4/migration/Menu/MenuList.spec.ts b/packages/v4/migration/Menu/MenuList.spec.ts index f50a42b6..8c03861c 100644 --- a/packages/v4/migration/Menu/MenuList.spec.ts +++ b/packages/v4/migration/Menu/MenuList.spec.ts @@ -25,8 +25,8 @@ async function render(): Promise<{ `); return { root, - outer: getInstance(root.querySelector('#outer-list')!, 'MenuList')!, - nested: getInstance(root.querySelector('#nested-list')!, 'MenuList')!, + outer: getInstance(root.querySelector('#outer-list'), 'MenuList')!, + nested: getInstance(root.querySelector('#nested-list'), 'MenuList')!, outerLink: root.querySelector('#outer-link') as HTMLElement, nestedLink: root.querySelector('#nested-link') as HTMLElement, }; diff --git a/packages/v4/migration/Toaster/Toaster.spec.ts b/packages/v4/migration/Toaster/Toaster.spec.ts index ed4de1c1..5f69d1b8 100644 --- a/packages/v4/migration/Toaster/Toaster.spec.ts +++ b/packages/v4/migration/Toaster/Toaster.spec.ts @@ -41,7 +41,7 @@ async function render(): Promise<{ root: HTMLElement; instance: Toaster }> { await settle(); return { root, - instance: getInstance(root.querySelector('[data-component="Toaster"]')!, 'Toaster')!, + instance: getInstance(root.querySelector('[data-component="Toaster"]'), 'Toaster')!, }; } diff --git a/packages/v4/src/Base.spec.ts b/packages/v4/src/Base.spec.ts index 175823b5..1e2122a8 100644 --- a/packages/v4/src/Base.spec.ts +++ b/packages/v4/src/Base.spec.ts @@ -76,7 +76,7 @@ describe('$emit and delegation', () => { await settle(); const li = root.querySelector('[data-component="TodoItem"]'); - const instance = getInstance(li!, 'TodoItem')!; + const instance = getInstance(li, 'TodoItem')!; const seen: unknown[] = []; root.addEventListener('ping', (event) => { seen.push((event as CustomEvent).detail); @@ -93,7 +93,7 @@ describe('$emit and delegation', () => { await settle(); const li = root.querySelector('[data-component="TodoItem"]'); - const instance = getInstance(li!, 'TodoItem')!; + const instance = getInstance(li, 'TodoItem')!; const seen: unknown[] = []; root.addEventListener('ping', (event) => seen.push((event as CustomEvent).detail)); @@ -106,7 +106,7 @@ describe('$emit and delegation', () => { await settle(); const li = root.querySelector('[data-component="TodoItem"]'); - const instance = getInstance(li!, 'TodoItem')!; + const instance = getInstance(li, 'TodoItem')!; const log = captureDiagnostics(); const seen: unknown[] = []; root.addEventListener('ping', (event) => seen.push((event as CustomEvent).detail)); @@ -797,7 +797,7 @@ describe('$refs', () => { expect(owner.$refs.item).toBe(root.querySelector('[data-ref="item"]')); await settle(); - expect(getInstance(root.lastElementChild!, 'RefReadInserted')!.$isMounted).toBe(true); + expect(getInstance(root.lastElementChild, 'RefReadInserted')!.$isMounted).toBe(true); }); }); @@ -1626,7 +1626,7 @@ describe('lifecycle', () => { await settle(); const countInstance = getInstance( - root.querySelector('[data-component="TodoCount"]')!, + root.querySelector('[data-component="TodoCount"]'), 'TodoCount', )!; expect(countInstance.cleanupCalls).toBe(0); diff --git a/packages/v4/src/context-subscription.spec.ts b/packages/v4/src/context-subscription.spec.ts index 0110518a..68bf0c30 100644 --- a/packages/v4/src/context-subscription.spec.ts +++ b/packages/v4/src/context-subscription.spec.ts @@ -130,7 +130,7 @@ describe('subscribeContext', () => { `); await settle(); - const member = getInstance(root.querySelector('span')!, 'ReanswerMember')!; + const member = getInstance(root.querySelector('span'), 'ReanswerMember')!; expect(member.seen).toEqual(['page']); root.querySelector('#scope')?.setAttribute('data-component', 'ReanswerScope'); @@ -165,7 +165,7 @@ describe('subscribeContext', () => { `); await settle(); - const member = getInstance(root.querySelector('span')!, 'DistanceMember')!; + const member = getInstance(root.querySelector('span'), 'DistanceMember')!; expect(member.seen).toEqual(['inner']); root.querySelector('#outer')?.setAttribute('data-component', 'DistanceScope'); diff --git a/packages/v4/src/context.spec.ts b/packages/v4/src/context.spec.ts index de1a55d9..1fe5b847 100644 --- a/packages/v4/src/context.spec.ts +++ b/packages/v4/src/context.spec.ts @@ -445,7 +445,7 @@ describe('provide/inject', () => { const counter = getInstance(root, 'Counter')!; const button = root.querySelector('button'); - const control = getInstance(button!, 'CounterBtn')!; + const control = getInstance(button, 'CounterBtn')!; button?.click(); button?.click(); diff --git a/packages/v4/src/decorators.spec.ts b/packages/v4/src/decorators.spec.ts index 7eca02cb..5724f336 100644 --- a/packages/v4/src/decorators.spec.ts +++ b/packages/v4/src/decorators.spec.ts @@ -644,7 +644,7 @@ describe('@on', () => { const parent = getInstance(root, 'DecoParent')!; const child = getInstance( - root.querySelector('[data-component="DecoChild"]')!, + root.querySelector('[data-component="DecoChild"]'), 'DecoChild', )!; @@ -660,7 +660,7 @@ describe('@on', () => { const parent = getInstance(root, 'DecoParent')!; const child = getInstance( - root.querySelector('[data-component="DecoChild"]')!, + root.querySelector('[data-component="DecoChild"]'), 'DecoChild', )!; @@ -718,7 +718,7 @@ describe('@on', () => { const parent = getInstance(root, 'DecoParent')!; const child = getInstance( - root.querySelector('[data-component="DecoChild"]')!, + root.querySelector('[data-component="DecoChild"]'), 'DecoChild', )!; @@ -849,9 +849,9 @@ describe('@on', () => { await settle(); const parent = getInstance(root, 'SubTargetParent')!; - const sub = getInstance(root.querySelector('[data-component="SubKind"]')!, 'SubKind')!; + const sub = getInstance(root.querySelector('[data-component="SubKind"]'), 'SubKind')!; const base = getInstance( - root.querySelector('[data-component="BaseKind"]')!, + root.querySelector('[data-component="BaseKind"]'), 'BaseKind', )!; @@ -1043,7 +1043,7 @@ describe('@on stacked with @read / @write', () => { const instance = getInstance(root, 'PhasedHandlers')!; const child = getInstance( - root.querySelector('[data-component="PhasedChild"]')!, + root.querySelector('[data-component="PhasedChild"]'), 'PhasedChild', )!; @@ -1101,7 +1101,7 @@ describe('@provide / @inject', () => { const parent = getInstance(root, 'DecoParent')!; const child = getInstance( - root.querySelector('[data-component="DecoChild"]')!, + root.querySelector('[data-component="DecoChild"]'), 'DecoChild', )!; diff --git a/packages/v4/src/group.spec.ts b/packages/v4/src/group.spec.ts index 467859e3..f0e2ca91 100644 --- a/packages/v4/src/group.spec.ts +++ b/packages/v4/src/group.spec.ts @@ -211,11 +211,11 @@ function disclosureMarkup(id: string, open = false): string { } function disclosure(root: ParentNode, id: string): Disclosure { - return getInstance(root.querySelector(`#${id}`)!, 'Disclosure')!; + return getInstance(root.querySelector(`#${id}`), 'Disclosure')!; } function group(root: ParentNode, id: string): DisclosureGroup { - return getInstance(root.querySelector(`#${id}`)!, 'DisclosureGroup')!; + return getInstance(root.querySelector(`#${id}`), 'DisclosureGroup')!; } describe('a group of disclosures', () => { diff --git a/packages/v4/src/instances.ts b/packages/v4/src/instances.ts index a7f472fb..96b4d1d0 100644 --- a/packages/v4/src/instances.ts +++ b/packages/v4/src/instances.ts @@ -120,10 +120,25 @@ export function getUnmountedInstances( * answer to "is this element's instance there yet", which every plural form * loses by returning a list. * + * `el` accepts `null` so that a `querySelector()` result can be passed + * straight through. The two ways of having no instance — no element, and an + * element without one — are the same answer to the caller, and `undefined` + * says it for both. Narrowing the parameter to `Element` would buy nothing + * back: the body reads an optional map either way, so the only thing a + * stricter type produces is a `!` at every call site that asserts something + * the function never needed. + * + * The *return* stays `T | undefined`, and that asymmetry is deliberate. An + * absent element is a fact the caller may reasonably not know; an absent + * instance is a fact the caller must handle. + * * There is deliberately no `getMountedInstance`. The result is one object, so * a caller who needs the live one reads `.$isMounted` on it — a second export * would only hide that check behind a `undefined` that means two things. */ -export function getInstance(el: Element, name: string): T | undefined { - return el[INSTANCES]?.get(name) as T | undefined; +export function getInstance( + el: Element | null, + name: string, +): T | undefined { + return el?.[INSTANCES]?.get(name) as T | undefined; } diff --git a/packages/v4/src/props.spec.ts b/packages/v4/src/props.spec.ts index 7d2a48ef..ffcc486c 100644 --- a/packages/v4/src/props.spec.ts +++ b/packages/v4/src/props.spec.ts @@ -174,7 +174,7 @@ describe('a component declared with a props type parameter', () => { `; await settle(); - const instance = getInstance(document.querySelector('form')!, 'Extensible')!; + const instance = getInstance(document.querySelector('form'), 'Extensible')!; expect(instance.$options.target).toBe('here'); expect(instance.$refs.btn).toBeInstanceOf(HTMLButtonElement); expect(instance.$refs.items).toHaveLength(2); diff --git a/packages/v4/src/responsive-options.spec.ts b/packages/v4/src/responsive-options.spec.ts index 9582e87c..4caee4a4 100644 --- a/packages/v4/src/responsive-options.spec.ts +++ b/packages/v4/src/responsive-options.spec.ts @@ -138,7 +138,7 @@ describe('responsive options', () => { data-option-label:large="wide">

`, ); await settle(); - const label = getInstance