@lenne.tech/nuxt-extensions 1.13.0 → 1.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -186,22 +186,42 @@ export default defineNuxtRouteMiddleware((to) => {
186
186
  force-patched on hydration (vuejs/core#9083) while the label's `for` — a prop on reka-ui's
187
187
  `Label` component — is not. The label keeps the stale value and the association breaks.
188
188
 
189
- **What this module adds:** `runtime/plugins/form-label-association.client.ts` re-points
190
- dangling Nuxt UI field labels at the control in their own field, at `app:mounted` and for a
191
- bounded window afterwards (`maxRepairMs`, default 1500 ms).
189
+ **What this module adds:** `runtime/plugins/form-label-association.client.ts` repairs three
190
+ associations inside Nuxt UI fields whose label no longer resolves — `<label for>`,
191
+ `aria-labelledby` on group roots, and `aria-describedby` on controls — at `app:mounted`, for a
192
+ bounded window afterwards (`maxRepairMs`, default 1500 ms), and via a `MutationObserver` for
193
+ subtrees that hydrate later (`observeDeferred`, default on).
192
194
 
193
195
  **What this means when you debug a consuming project:**
194
196
 
195
- - **A `for` attribute that changes after mount is this plugin, not a bug.** It logs a
196
- `console.warn` in development naming how many labels it repaired.
197
- - **It refuses to guess.** The repair only fires when the field holds exactly one eligible
198
- control. Radio and checkbox groups, hidden form-value proxies, `aria-hidden`/`disabled`
199
- controls and button-only fields are skipped on purpose — binding a group caption to its
200
- first option would submit a value the user never chose.
201
- - **Only Nuxt UI's own field labels are touched** (`label[data-slot="label"][for]`). Labels
202
- your application wrote are never rewritten.
203
- - **Do not "fix" a skipped group by loosening the rule.** Inert beats wrong here; the correct
204
- fix for a group is an SSR-stable id at the source.
197
+ - **A `for` / `aria-labelledby` / `aria-describedby` that changes after mount is this plugin, not
198
+ a bug.** It logs once per page in development, naming the repaired fields. Repaired labels
199
+ carry `data-lt-label-repaired`.
200
+ - **`aria-describedby` is the one people miss.** The error container's id IS force-patched on
201
+ hydration; the reference to it is not, so validation errors are visible but never announced
202
+ (WCAG 3.3.1 / 3.3.3). If a project reports "screen reader stays silent on form errors", check
203
+ whether this plugin is disabled before looking anywhere else.
204
+ - **It refuses to guess.** The `for` repair fires only when the field holds exactly one eligible
205
+ control. Groups get `aria-labelledby` instead — a `for` on a `<div role="radiogroup">` is inert
206
+ however carefully it is chosen.
207
+ - **`disabled` and `readonly` fields ARE repaired**, and must stay that way. They are rendered
208
+ and announced, so WCAG 1.3.1 / 4.1.2 apply unchanged; WCAG's only carve-out for inactive
209
+ components is contrast. Excluding `disabled` was tried in 1.13.0 and reproduced the very defect
210
+ the plugin repairs on every read-only form — and additionally mis-bound partially disabled radio
211
+ groups, so a click on one caption selected a different option.
212
+ - **`[data-hidden]` alone does NOT mean "reka proxy".** The missing `id` is what means that.
213
+ `UFileUpload` renders the user's real file input through the same mechanism and stamps the
214
+ field id on it — excluding it unqualified takes out the field's only control.
215
+ - **Only Nuxt UI's own field labels are touched** (`label[data-slot="label"][for]`). Labels your
216
+ application wrote are never rewritten, and naming the application set itself is never
217
+ overwritten.
218
+ - **Never assert on a generated `for` or `id` value** in tests. Assert on the accessible name —
219
+ the ids come from `useId()` and are neither stable nor meaningful, and this plugin rewrites them.
220
+ - **If you change a selector in that file, run the mutation check.** `test/upstream-dom-contract.test.ts`
221
+ pins what reka-ui and @nuxt/ui actually render (both are devDependencies for exactly this
222
+ reason); `test/form-label-association.test.ts` pins our own pairing rules. Every guard has a
223
+ case that fails when the guard is removed. The 1.13.0 defect shipped because a fixture was
224
+ invented rather than observed, and the invented selector matched the invented fixture.
205
225
  - This is a **stopgap** for a framework-level divergence. Opt out with
206
226
  `ltExtensions.formLabelAssociation.enabled: false`.
207
227
 
@@ -239,5 +259,14 @@ fields at `app:beforeMount` and restores them at `app:mounted`, dispatching a sy
239
259
  1. **ALWAYS read source code** in `dist/runtime/` to understand available composables and components
240
260
  2. **Use `useLtAuth()`** for authentication — never implement auth manually
241
261
  3. **Check existing composables** before creating new ones — this module may already provide what you need
242
- 4. **Peer dependencies** (`better-auth`, `@better-auth/passkey`, `tus-js-client`) must be installed in the consuming project
262
+ 4. **Peer dependencies** (`better-auth`, `@better-auth/passkey`, `tus-js-client`) must be installed in the consuming project.
263
+ `better-auth` and `@better-auth/passkey` are pinned to **`>=1.7.1 <1.8.0`** — deliberately not a caret.
264
+ **They move in lock-step with `@lenne.tech/nest-server`.** better-auth is one protocol with two ends;
265
+ this module is the client end, nest-server the server end, and both declare the *same* narrow range.
266
+ Raising it means raising it in **both** framework repos and pinning the new version in **both** starters,
267
+ in one release. Never half of it, not even "just to unblock the frontend" — a split resolves as api on one
268
+ minor and app on another, 2FA activation fails at runtime, and *both* repos' `check` stays green because
269
+ each is internally consistent and only the assembled workspace has both halves.
270
+ `test/peer-dependency-ranges.test.ts` guards the declared range against the version actually tested;
271
+ `test/better-auth-contract.test.ts` pins the consumed better-auth surface.
243
272
  5. **AI types collision pitfall:** `LtAiPromptInput` is the CRUD input for the user-facing `LtAiPrompt` entity (used by `useLtAiPrompts().create/update`). The execution payload for `useLtAi.prompt()` / `.promptStream()` is `LtAiPromptRunInput`. Do not conflate them.
package/README.md CHANGED
@@ -23,16 +23,48 @@ Reusable Nuxt 4 composables, components, and Better-Auth integration for lenne.t
23
23
  > npx @lenne.tech/cli fullstack init my-project
24
24
  > ```
25
25
 
26
+ ## Requirements
27
+
28
+ | Dependency | Required version | Notes |
29
+ |---|---|---|
30
+ | Node | `>= 22` | |
31
+ | Nuxt | `^4.0.0` | |
32
+ | `better-auth` | `>=1.7.1 <1.8.0` | **required peer** — pin it, see below |
33
+ | `@better-auth/passkey` | `>=1.7.1 <1.8.0` | optional peer, passkey/WebAuthn only |
34
+ | `tus-js-client` | `>=4.0.0` | optional peer, resumable uploads only |
35
+ | `@lenne.tech/nest-server` | `>= 11.37.0` | **only if you use the backend** — see the lock-step rule |
36
+
37
+ **Why `better-auth` is pinned to one minor line, and why you must not widen it:**
38
+ better-auth is one protocol with two ends. This module is the client end and
39
+ `@lenne.tech/nest-server` is the server end, and they must resolve the **same**
40
+ better-auth minor. better-auth breaks in *minor* releases — 1.7 removed subpath
41
+ exports and changed the 2FA response shape — so a caret (`^1.7.1`) would re-open the
42
+ hole at 1.8. If the api and the app drift apart, 2FA activation fails at runtime with a
43
+ generic client error while **both** projects' checks stay green: each is internally
44
+ consistent, and only the assembled workspace has both halves.
45
+
46
+ Raising better-auth therefore means raising it in `@lenne.tech/nest-server` **and**
47
+ `@lenne.tech/nuxt-extensions` in one step.
48
+
26
49
  ## Installation
27
50
 
28
51
  ```bash
29
- npm install @lenne.tech/nuxt-extensions better-auth
52
+ npm install @lenne.tech/nuxt-extensions better-auth@1.7.1
30
53
  # Optional: For passkey support
31
- npm install @better-auth/passkey
54
+ npm install @better-auth/passkey@1.7.1
32
55
  # Optional: For TUS file uploads
33
56
  npm install tus-js-client
34
57
  ```
35
58
 
59
+ In a fullstack monorepo, pin it workspace-wide so the api and the app cannot drift:
60
+
61
+ ```yaml
62
+ # pnpm-workspace.yaml
63
+ overrides:
64
+ better-auth: 1.7.1
65
+ '@better-auth/passkey': 1.7.1
66
+ ```
67
+
36
68
  ## Features
37
69
 
38
70
  - **Better-Auth Integration** - Login, 2FA, Passkey/WebAuthn support
@@ -190,27 +222,39 @@ target, not a convenience. Tests using `getByRole('textbox', { name })` stop fin
190
222
 
191
223
  ### What the repair does
192
224
 
193
- After mount, and for a bounded window afterwards, every Nuxt UI field label whose target does
194
- not resolve is re-pointed at the control in its own field.
225
+ After mount, and for a bounded window afterwards, three associations are repaired inside each
226
+ Nuxt UI field whose label no longer resolves:
227
+
228
+ | Association | Repaired how | Why it needs its own path |
229
+ |---|---|---|
230
+ | `<label for>` → control | re-pointed at the field's control | `for` carries **both** the accessible name and click-to-focus |
231
+ | `aria-labelledby` on a group | caption id minted, group pointed at it | A radio group's id sits on a `<div role="radiogroup">`, and `for` only resolves against labelable elements — there it is inert however carefully it is chosen |
232
+ | `aria-describedby` → error / help | dangling tokens re-pointed, token-wise | The error container's id **is** force-patched on hydration; the reference to it is not. So the message is visible and never announced (WCAG 3.3.1 / 3.3.3) |
195
233
 
196
234
  **It refuses to guess.** A repair that fires where it should not is worse than the defect: a
197
- dangling label is inert and visible, a mis-pointed one is confidently wrong and silent, and
198
- on a radio or checkbox a click on the caption would change a value the user never chose. So
199
- the repair only fires when the field contains exactly **one** eligible control. Skipped
200
- deliberately:
235
+ dangling label is inert and visible, a mis-pointed one is confidently wrong and silent, and on a
236
+ radio or checkbox a click on the caption would change a value the user never chose. So the `for`
237
+ repair only fires when the field contains exactly **one** eligible control. Skipped deliberately:
201
238
 
202
239
  | Case | Why |
203
240
  |------|-----|
204
- | Radio / checkbox groups | One field root, many controls — binding every caption to the first option would submit a value nobody picked |
205
- | Hidden form-value proxies, `aria-hidden`, `disabled` | Not the user's control; pointing at one restores neither the name nor the click |
206
- | Fields whose only visible control is a `button` | Nothing safe to point at |
241
+ | Radio / checkbox groups | One field root, many controls — binding every caption to one item would submit a value nobody picked. Named via `aria-labelledby` instead |
242
+ | Reka's submit proxy (`[data-hidden]` with no `id`) | Not the user's control. The missing id is what identifies it — Nuxt UI's `UFileUpload` renders the *real* file input the same way, but stamps the field id on it |
243
+ | Anything `aria-hidden`, `hidden`, `type="hidden"` or `display:none` | Outside the accessibility tree, so a label pointing there would carry no name — the repair would report success and change nothing |
244
+ | A control that already has a working label | Multiple `<label>`s **concatenate** into one accessible name, so a second one produces a name matching no visible text |
245
+ | An id that is not unique in the document | `for` resolves through `getElementById`, which returns the *first* match — the label's click would go to the other element |
207
246
  | Labels your application wrote | Only Nuxt UI's own field labels (`data-slot="label"`) are touched |
208
247
 
209
- `for` is repaired rather than `aria-labelledby` added, because `for` carries **both** the
210
- accessible name and click-to-focus.
248
+ **`disabled` and `readonly` fields ARE repaired.** They are rendered, stay in the accessibility
249
+ tree and are announced, so WCAG 1.3.1 / 4.1.2 apply unchanged — and WCAG's only carve-out for
250
+ inactive components is contrast (1.4.3 / 1.4.11); there is no naming exemption. For a disabled
251
+ field the repair restores the **name only**, since a disabled control prevents click dispatch and
252
+ is not focusable. On a read-only form that is the whole value: it is a page people read rather
253
+ than operate. Excluding `disabled` was tried in 1.13.0 and reproduced the very defect this plugin
254
+ repairs on every read-only form.
211
255
 
212
- **This is a repair, not a cure.** The real fix is for the id not to diverge; this exists
213
- because the divergence sits in the framework stack rather than in any one application.
256
+ **This is a repair, not a cure.** The real fix is for the id not to diverge; this exists because
257
+ the divergence sits in the framework stack rather than in any one application.
214
258
 
215
259
  ### Opting out
216
260
 
@@ -219,15 +263,24 @@ export default defineNuxtConfig({
219
263
  ltExtensions: {
220
264
  formLabelAssociation: {
221
265
  enabled: false,
222
- // or keep it on and only change how long deferred subtrees are waited for:
223
- // maxRepairMs: 3000,
266
+ // Or keep it on and tune the two knobs:
267
+ // maxRepairMs: 3000, // window for deferred subtrees; clamped to [0, 30000]
268
+ // observeDeferred: false, // drop the MutationObserver for hydrate-on-visible content
224
269
  },
225
270
  },
226
271
  });
227
272
  ```
228
273
 
229
- Two real reasons to: your application assigns `for` itself and depends on those exact values,
230
- or your tests assert on literal `for` / `id` strings rather than on the accessible name.
274
+ Two real reasons to turn it off: your application assigns `for` itself and depends on those exact
275
+ values, or your tests assert on literal `for` / `id` strings rather than on the accessible name.
276
+ Fix the second reason instead — assert on the accessible name, which is what a user perceives.
277
+
278
+ ### Debugging it in a consuming project
279
+
280
+ - **A `for`, `aria-labelledby` or `aria-describedby` that changes after mount is this plugin.**
281
+ In development it logs once per page, naming the fields it repaired.
282
+ - Repaired labels carry `data-lt-label-repaired`, so an E2E suite can assert the repair count is
283
+ zero once the upstream divergence is fixed — and delete the plugin at that point.
231
284
 
232
285
  ## Pre-Hydration Input Preservation
233
286
 
package/dist/module.d.mts CHANGED
@@ -3,7 +3,7 @@ import { LtExtensionsModuleOptions } from '../dist/runtime/types/index.js';
3
3
  export * from '../dist/runtime/types/index.js';
4
4
 
5
5
  declare const name = "@lenne.tech/nuxt-extensions";
6
- declare const version = "1.13.0";
6
+ declare const version = "1.15.0";
7
7
  declare const configKey = "ltExtensions";
8
8
  declare const DEFAULT_LT_AUTH_STATE_COOKIE = "lt-auth-state";
9
9
  declare const DEFAULT_LT_JWT_TOKEN_COOKIE = "lt-jwt-token";
package/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "configKey": "ltExtensions",
3
3
  "name": "@lenne.tech/nuxt-extensions",
4
- "version": "1.13.0",
4
+ "version": "1.15.0",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.3",
7
7
  "unbuild": "3.6.1"
package/dist/module.mjs CHANGED
@@ -1,7 +1,8 @@
1
1
  import { defineNuxtModule, createResolver, tryResolveModule, addImports, addComponent, addPlugin, addRouteMiddleware } from '@nuxt/kit';
2
+ import { pathToFileURL } from 'node:url';
2
3
 
3
4
  const name = "@lenne.tech/nuxt-extensions";
4
- const version = "1.13.0";
5
+ const version = "1.15.0";
5
6
  const configKey = "ltExtensions";
6
7
  const DEFAULT_LT_AUTH_STATE_COOKIE = "lt-auth-state";
7
8
  const DEFAULT_LT_JWT_TOKEN_COOKIE = "lt-jwt-token";
@@ -74,7 +75,7 @@ const module$1 = defineNuxtModule({
74
75
  preHydrationInput: { ...defaultOptions.preHydrationInput, ...options.preHydrationInput },
75
76
  tus: { ...defaultOptions.tus, ...options.tus }
76
77
  };
77
- const passkeyAvailable = !!await tryResolveModule("@better-auth/passkey/client", new URL(import.meta.url));
78
+ const passkeyAvailable = !!await tryResolveModule("@better-auth/passkey/client", [new URL(import.meta.url), pathToFileURL(`${nuxt.options.rootDir}/`)]);
78
79
  if (!passkeyAvailable) {
79
80
  const passkeyStub = resolve("./runtime/lib/passkey-stub");
80
81
  nuxt.options.alias["@better-auth/passkey/client"] = passkeyStub;
@@ -130,7 +131,8 @@ const module$1 = defineNuxtModule({
130
131
  },
131
132
  formLabelAssociation: {
132
133
  enabled: resolvedOptions.formLabelAssociation?.enabled ?? true,
133
- maxRepairMs: resolvedOptions.formLabelAssociation?.maxRepairMs ?? 1500
134
+ maxRepairMs: resolvedOptions.formLabelAssociation?.maxRepairMs ?? 1500,
135
+ observeDeferred: resolvedOptions.formLabelAssociation?.observeDeferred ?? true
134
136
  },
135
137
  preHydrationInput: {
136
138
  enabled: resolvedOptions.preHydrationInput?.enabled ?? true,
@@ -232,11 +234,14 @@ const module$1 = defineNuxtModule({
232
234
  }
233
235
  nuxt.options.build.transpile.push(resolve("./runtime"));
234
236
  nuxt.options.build.transpile.push("tus-js-client");
235
- console.log(`[${name}] Module loaded with config:`, {
236
- auth: resolvedOptions.auth?.enabled ? "enabled" : "disabled",
237
- i18nAutoMerge: resolvedOptions.i18n?.autoMerge,
238
- tusEndpoint: resolvedOptions.tus?.defaultEndpoint
239
- });
237
+ console.log(`[${name}] v${version}`);
238
+ if (nuxt.options.dev) {
239
+ console.log(`[${name}] Module loaded with config:`, {
240
+ auth: resolvedOptions.auth?.enabled ? "enabled" : "disabled",
241
+ i18nAutoMerge: resolvedOptions.i18n?.autoMerge,
242
+ tusEndpoint: resolvedOptions.tus?.defaultEndpoint
243
+ });
244
+ }
240
245
  }
241
246
  });
242
247
 
@@ -184,8 +184,8 @@ export declare function createLtAuthClient(config?: LtAuthClientConfig): {
184
184
  }>;
185
185
  }>;
186
186
  };
187
- passkey: any;
188
- admin: any;
187
+ passkey: Record<string, unknown> | undefined;
188
+ admin: Record<string, unknown> | undefined;
189
189
  $Infer: {
190
190
  Session: {
191
191
  user: import("better-auth").StripEmptyObjects<{
@@ -302,36 +302,7 @@ export declare function createLtAuthClient(config?: LtAuthClientConfig): {
302
302
  changePassword: (params: {
303
303
  currentPassword: string;
304
304
  newPassword: string;
305
- }, options?: any) => Promise<(Omit<{
306
- token: string | null;
307
- user: {
308
- id: string;
309
- createdAt: Date;
310
- updatedAt: Date;
311
- email: string;
312
- emailVerified: boolean;
313
- name: string;
314
- image?: string | null | undefined;
315
- } & Record<string, any> & {
316
- id: string;
317
- createdAt: Date;
318
- updatedAt: Date;
319
- email: string;
320
- emailVerified: boolean;
321
- name: string;
322
- image?: string | null | undefined;
323
- };
324
- }, "user"> & {
325
- user: import("better-auth").StripEmptyObjects<{
326
- id: string;
327
- createdAt: Date;
328
- updatedAt: Date;
329
- email: string;
330
- emailVerified: boolean;
331
- name: string;
332
- image?: string | null | undefined;
333
- }>;
334
- }) | {
305
+ }, options?: unknown) => Promise<{
335
306
  data: Omit<{
336
307
  token: string | null;
337
308
  user: {
@@ -378,9 +349,7 @@ export declare function createLtAuthClient(config?: LtAuthClientConfig): {
378
349
  resetPassword: (params: {
379
350
  newPassword: string;
380
351
  token: string;
381
- }, options?: any) => Promise<{
382
- status: boolean;
383
- } | {
352
+ }, options?: unknown) => Promise<{
384
353
  data: {
385
354
  status: boolean;
386
355
  };
@@ -402,30 +371,7 @@ export declare function createLtAuthClient(config?: LtAuthClientConfig): {
402
371
  email: string;
403
372
  password: string;
404
373
  rememberMe?: boolean;
405
- }, options?: any) => Promise<(Omit<{
406
- redirect: boolean;
407
- token: string;
408
- url?: string | undefined;
409
- user: {
410
- id: string;
411
- createdAt: Date;
412
- updatedAt: Date;
413
- email: string;
414
- emailVerified: boolean;
415
- name: string;
416
- image?: string | null | undefined | undefined;
417
- };
418
- }, "user"> & {
419
- user: import("better-auth").StripEmptyObjects<{
420
- id: string;
421
- createdAt: Date;
422
- updatedAt: Date;
423
- email: string;
424
- emailVerified: boolean;
425
- name: string;
426
- image?: string | null | undefined;
427
- }>;
428
- }) | {
374
+ }, options?: unknown) => Promise<{
429
375
  data: Omit<{
430
376
  redirect: boolean;
431
377
  token: string;
@@ -464,7 +410,7 @@ export declare function createLtAuthClient(config?: LtAuthClientConfig): {
464
410
  * Sign in with passkey (pass through to base client - provided by passkeyClient plugin)
465
411
  * @see https://www.better-auth.com/docs/plugins/passkey
466
412
  */
467
- passkey: any;
413
+ passkey: ((...args: unknown[]) => Promise<unknown>) | undefined;
468
414
  social: <FetchOptions extends import("better-auth").ClientFetchOption<Partial<{
469
415
  provider: (string & {}) | "linear" | "huggingface" | "github" | "apple" | "atlassian" | "cognito" | "discord" | "facebook" | "figma" | "microsoft" | "google" | "slack" | "spotify" | "twitch" | "twitter" | "dropbox" | "kick" | "linkedin" | "gitlab" | "tiktok" | "reddit" | "roblox" | "salesforce" | "vk" | "zoom" | "notion" | "kakao" | "naver" | "line" | "paybin" | "paypal" | "polar" | "railway" | "vercel" | "wechat";
470
416
  callbackURL?: string | undefined;
@@ -577,49 +523,7 @@ export declare function createLtAuthClient(config?: LtAuthClientConfig): {
577
523
  email: string;
578
524
  name: string;
579
525
  password: string;
580
- } & Record<string, unknown>, options?: any) => Promise<(Omit<{
581
- token: null;
582
- user: {
583
- id: string;
584
- createdAt: Date;
585
- updatedAt: Date;
586
- email: string;
587
- emailVerified: boolean;
588
- name: string;
589
- image?: string | null | undefined | undefined;
590
- };
591
- }, "user"> & {
592
- user: import("better-auth").StripEmptyObjects<{
593
- id: string;
594
- createdAt: Date;
595
- updatedAt: Date;
596
- email: string;
597
- emailVerified: boolean;
598
- name: string;
599
- image?: string | null | undefined;
600
- }>;
601
- }) | (Omit<{
602
- token: string;
603
- user: {
604
- id: string;
605
- createdAt: Date;
606
- updatedAt: Date;
607
- email: string;
608
- emailVerified: boolean;
609
- name: string;
610
- image?: string | null | undefined | undefined;
611
- };
612
- }, "user"> & {
613
- user: import("better-auth").StripEmptyObjects<{
614
- id: string;
615
- createdAt: Date;
616
- updatedAt: Date;
617
- email: string;
618
- emailVerified: boolean;
619
- name: string;
620
- image?: string | null | undefined;
621
- }>;
622
- }) | {
526
+ } & Record<string, unknown>, options?: unknown) => Promise<{
623
527
  data: (Omit<{
624
528
  token: null;
625
529
  user: {
@@ -674,7 +578,34 @@ export declare function createLtAuthClient(config?: LtAuthClientConfig): {
674
578
  };
675
579
  }>;
676
580
  };
677
- twoFactor: any;
581
+ twoFactor: {
582
+ /**
583
+ * Disable 2FA (password is hashed before sending)
584
+ */
585
+ disable: (params: {
586
+ password: string;
587
+ }, options?: unknown) => Promise<unknown>;
588
+ /**
589
+ * Enable 2FA (password is hashed before sending)
590
+ */
591
+ enable: (params: {
592
+ password: string;
593
+ }, options?: unknown) => Promise<unknown>;
594
+ /**
595
+ * Generate backup codes (password is hashed before sending)
596
+ */
597
+ generateBackupCodes: (params: {
598
+ password: string;
599
+ }, options?: unknown) => Promise<unknown>;
600
+ /**
601
+ * Verify TOTP code (pass through to base client)
602
+ */
603
+ verifyTotp: ((...args: unknown[]) => Promise<unknown>) | undefined;
604
+ /**
605
+ * Verify backup code (pass through to base client)
606
+ */
607
+ verifyBackupCode: ((...args: unknown[]) => Promise<unknown>) | undefined;
608
+ };
678
609
  verifyEmail: <FetchOptions extends import("better-auth").ClientFetchOption<never, Partial<{
679
610
  token: string;
680
611
  callbackURL?: string | undefined;
@@ -78,15 +78,15 @@ export function createLtAuthClient(config = {}) {
78
78
  },
79
79
  plugins
80
80
  });
81
+ const withPlugins = baseClient;
82
+ const signInWithPlugins = baseClient.signIn;
81
83
  return {
82
84
  // Spread all base client properties and methods
83
85
  ...baseClient,
84
86
  // Explicitly pass through methods not captured by spread operator
85
87
  useSession: baseClient.useSession,
86
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
87
- passkey: baseClient.passkey,
88
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
89
- admin: baseClient.admin,
88
+ passkey: withPlugins.passkey,
89
+ admin: withPlugins.admin,
90
90
  $Infer: baseClient.$Infer,
91
91
  $fetch: baseClient.$fetch,
92
92
  $store: baseClient.$store,
@@ -94,7 +94,6 @@ export function createLtAuthClient(config = {}) {
94
94
  /**
95
95
  * Change password for an authenticated user (both passwords are hashed)
96
96
  */
97
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
98
97
  changePassword: async (params, options) => {
99
98
  const [hashedCurrent, hashedNew] = await Promise.all([ltSha256(params.currentPassword), ltSha256(params.newPassword)]);
100
99
  return baseClient.changePassword?.({ currentPassword: hashedCurrent, newPassword: hashedNew }, options);
@@ -102,7 +101,6 @@ export function createLtAuthClient(config = {}) {
102
101
  /**
103
102
  * Reset password with token (new password is hashed before sending)
104
103
  */
105
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
106
104
  resetPassword: async (params, options) => {
107
105
  const hashedPassword = await ltSha256(params.newPassword);
108
106
  return baseClient.resetPassword?.({ newPassword: hashedPassword, token: params.token }, options);
@@ -113,7 +111,6 @@ export function createLtAuthClient(config = {}) {
113
111
  /**
114
112
  * Sign in with email and password (password is hashed before sending)
115
113
  */
116
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
117
114
  email: async (params, options) => {
118
115
  const hashedPassword = await ltSha256(params.password);
119
116
  return baseClient.signIn.email({ ...params, password: hashedPassword }, options);
@@ -122,8 +119,7 @@ export function createLtAuthClient(config = {}) {
122
119
  * Sign in with passkey (pass through to base client - provided by passkeyClient plugin)
123
120
  * @see https://www.better-auth.com/docs/plugins/passkey
124
121
  */
125
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
126
- passkey: baseClient.signIn.passkey
122
+ passkey: signInWithPlugins.passkey
127
123
  },
128
124
  // Explicitly pass through signOut (not captured by spread operator)
129
125
  signOut: baseClient.signOut,
@@ -133,7 +129,6 @@ export function createLtAuthClient(config = {}) {
133
129
  /**
134
130
  * Sign up with email and password (password is hashed before sending)
135
131
  */
136
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
137
132
  email: async (params, options) => {
138
133
  const hashedPassword = await ltSha256(params.password);
139
134
  return baseClient.signUp.email({ ...params, password: hashedPassword }, options);
@@ -141,42 +136,36 @@ export function createLtAuthClient(config = {}) {
141
136
  },
142
137
  // Override twoFactor to hash passwords (provided by twoFactorClient plugin)
143
138
  twoFactor: {
144
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
145
- ...baseClient.twoFactor,
139
+ ...withPlugins.twoFactor,
146
140
  /**
147
141
  * Disable 2FA (password is hashed before sending)
148
142
  */
149
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
150
143
  disable: async (params, options) => {
151
144
  const hashedPassword = await ltSha256(params.password);
152
- return baseClient.twoFactor.disable({ password: hashedPassword }, options);
145
+ return withPlugins.twoFactor?.disable({ password: hashedPassword }, options);
153
146
  },
154
147
  /**
155
148
  * Enable 2FA (password is hashed before sending)
156
149
  */
157
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
158
150
  enable: async (params, options) => {
159
151
  const hashedPassword = await ltSha256(params.password);
160
- return baseClient.twoFactor.enable({ password: hashedPassword }, options);
152
+ return withPlugins.twoFactor?.enable({ password: hashedPassword }, options);
161
153
  },
162
154
  /**
163
155
  * Generate backup codes (password is hashed before sending)
164
156
  */
165
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
166
157
  generateBackupCodes: async (params, options) => {
167
158
  const hashedPassword = await ltSha256(params.password);
168
- return baseClient.twoFactor.generateBackupCodes({ password: hashedPassword }, options);
159
+ return withPlugins.twoFactor?.generateBackupCodes({ password: hashedPassword }, options);
169
160
  },
170
161
  /**
171
162
  * Verify TOTP code (pass through to base client)
172
163
  */
173
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
174
- verifyTotp: baseClient.twoFactor.verifyTotp,
164
+ verifyTotp: withPlugins.twoFactor?.verifyTotp,
175
165
  /**
176
166
  * Verify backup code (pass through to base client)
177
167
  */
178
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
179
- verifyBackupCode: baseClient.twoFactor.verifyBackupCode
168
+ verifyBackupCode: withPlugins.twoFactor?.verifyBackupCode
180
169
  }
181
170
  };
182
171
  }
@@ -11,6 +11,16 @@
11
11
  * so the factory below is normally never called. It stays a no-op (rather than
12
12
  * throwing) to keep the direct `createLtAuthClient()` entry point working, whose
13
13
  * own `enablePasskey` default is `true`.
14
+ *
15
+ * WHY this file exports more than `passkeyClient`:
16
+ * the alias is installed into `nuxt.options.alias` and `nuxt.options.nitro.alias`,
17
+ * which rewrites the specifier for the WHOLE consumer app — not just for our
18
+ * `auth-client.ts`. A consumer (or another dependency) importing any other name
19
+ * from `@better-auth/passkey/client` would then hit
20
+ * `"PASSKEY_ERROR_CODES" is not exported by …/passkey-stub` — the same class of
21
+ * unresolved-export build failure this stub exists to prevent, merely relocated.
22
+ * So the stub mirrors the real package's full export surface.
23
+ * `test/optional-peers.test.ts` pins that surface against the real package.
14
24
  */
15
25
  /**
16
26
  * No-op replacement for the real `passkeyClient()` Better-Auth client plugin.
@@ -21,3 +31,86 @@
21
31
  export declare function passkeyClient(): {
22
32
  id: "lt-passkey-unavailable";
23
33
  };
34
+ /**
35
+ * Mirror of the real package's error-code table.
36
+ *
37
+ * Frozen and value-complete so a consumer that reads a code for a message or an
38
+ * equality check still gets a defined value instead of a `TypeError` on
39
+ * `undefined`. The codes are the contract; the messages are best-effort copies.
40
+ */
41
+ export declare const PASSKEY_ERROR_CODES: Readonly<{
42
+ AUTH_CANCELLED: {
43
+ code: string;
44
+ message: string;
45
+ };
46
+ AUTHENTICATION_FAILED: {
47
+ code: string;
48
+ message: string;
49
+ };
50
+ CHALLENGE_NOT_FOUND: {
51
+ code: string;
52
+ message: string;
53
+ };
54
+ FAILED_TO_UPDATE_PASSKEY: {
55
+ code: string;
56
+ message: string;
57
+ };
58
+ FAILED_TO_VERIFY_REGISTRATION: {
59
+ code: string;
60
+ message: string;
61
+ };
62
+ PASSKEY_NOT_FOUND: {
63
+ code: string;
64
+ message: string;
65
+ };
66
+ PREVIOUSLY_REGISTERED: {
67
+ code: string;
68
+ message: string;
69
+ };
70
+ REGISTRATION_CANCELLED: {
71
+ code: string;
72
+ message: string;
73
+ };
74
+ RESOLVE_USER_REQUIRED: {
75
+ code: string;
76
+ message: string;
77
+ };
78
+ RESOLVED_USER_INVALID: {
79
+ code: string;
80
+ message: string;
81
+ };
82
+ SESSION_REQUIRED: {
83
+ code: string;
84
+ message: string;
85
+ };
86
+ UNABLE_TO_CREATE_SESSION: {
87
+ code: string;
88
+ message: string;
89
+ };
90
+ UNKNOWN_ERROR: {
91
+ code: string;
92
+ message: string;
93
+ };
94
+ USER_NOT_FOUND: {
95
+ code: string;
96
+ message: string;
97
+ };
98
+ YOU_ARE_NOT_ALLOWED_TO_REGISTER_THIS_PASSKEY: {
99
+ code: string;
100
+ message: string;
101
+ };
102
+ }>;
103
+ /**
104
+ * No-op replacement for the real `getPasskeyActions()`.
105
+ *
106
+ * Every action rejects rather than resolving falsely: a passkey ceremony that
107
+ * silently "succeeds" without the package would leave the caller believing a
108
+ * credential exists. The rejection names the missing dependency.
109
+ */
110
+ export declare function getPasskeyActions(..._args: unknown[]): {
111
+ addPasskey: () => Promise<never>;
112
+ deletePasskey: () => Promise<never>;
113
+ listUserPasskeys: () => Promise<never>;
114
+ signInPasskey: () => Promise<never>;
115
+ updatePasskey: () => Promise<never>;
116
+ };
@@ -1,10 +1,50 @@
1
1
  let warned = false;
2
- export function passkeyClient() {
3
- if (!warned) {
4
- warned = true;
5
- console.warn(
6
- '[@lenne.tech/nuxt-extensions] Passkey support is unavailable: the optional peer dependency "@better-auth/passkey" is not installed. Install it to use passkeys, or pass `enablePasskey: false` to createLtAuthClient() to silence this warning.'
7
- );
2
+ function warnOnce(entryPoint) {
3
+ if (warned) {
4
+ return;
8
5
  }
6
+ warned = true;
7
+ console.warn(
8
+ `[@lenne.tech/nuxt-extensions] Passkey support is unavailable: the optional peer dependency "@better-auth/passkey" is not installed. Install it to use passkeys, or pass \`enablePasskey: false\` to createLtAuthClient() to silence this warning. (reached via ${entryPoint})`
9
+ );
10
+ }
11
+ export function passkeyClient() {
12
+ warnOnce("passkeyClient()");
9
13
  return { id: "lt-passkey-unavailable" };
10
14
  }
15
+ export const PASSKEY_ERROR_CODES = Object.freeze({
16
+ AUTH_CANCELLED: { code: "AUTH_CANCELLED", message: "Auth cancelled" },
17
+ AUTHENTICATION_FAILED: { code: "AUTHENTICATION_FAILED", message: "Authentication failed" },
18
+ CHALLENGE_NOT_FOUND: { code: "CHALLENGE_NOT_FOUND", message: "Challenge not found" },
19
+ FAILED_TO_UPDATE_PASSKEY: { code: "FAILED_TO_UPDATE_PASSKEY", message: "Failed to update passkey" },
20
+ FAILED_TO_VERIFY_REGISTRATION: { code: "FAILED_TO_VERIFY_REGISTRATION", message: "Failed to verify registration" },
21
+ PASSKEY_NOT_FOUND: { code: "PASSKEY_NOT_FOUND", message: "Passkey not found" },
22
+ PREVIOUSLY_REGISTERED: { code: "PREVIOUSLY_REGISTERED", message: "Previously registered" },
23
+ REGISTRATION_CANCELLED: { code: "REGISTRATION_CANCELLED", message: "Registration cancelled" },
24
+ RESOLVE_USER_REQUIRED: {
25
+ code: "RESOLVE_USER_REQUIRED",
26
+ message: "Passkey registration requires either an authenticated session or a resolveUser callback when requireSession is false"
27
+ },
28
+ RESOLVED_USER_INVALID: { code: "RESOLVED_USER_INVALID", message: "Resolved user is invalid" },
29
+ SESSION_REQUIRED: { code: "SESSION_REQUIRED", message: "Passkey registration requires an authenticated session" },
30
+ UNABLE_TO_CREATE_SESSION: { code: "UNABLE_TO_CREATE_SESSION", message: "Unable to create session" },
31
+ UNKNOWN_ERROR: { code: "UNKNOWN_ERROR", message: "Unknown error" },
32
+ USER_NOT_FOUND: { code: "USER_NOT_FOUND", message: "User not found" },
33
+ YOU_ARE_NOT_ALLOWED_TO_REGISTER_THIS_PASSKEY: {
34
+ code: "YOU_ARE_NOT_ALLOWED_TO_REGISTER_THIS_PASSKEY",
35
+ message: "You are not allowed to register this passkey"
36
+ }
37
+ });
38
+ export function getPasskeyActions(..._args) {
39
+ warnOnce("getPasskeyActions()");
40
+ const unavailable = async () => {
41
+ throw new Error('[@lenne.tech/nuxt-extensions] Passkey action unavailable: the optional peer dependency "@better-auth/passkey" is not installed.');
42
+ };
43
+ return {
44
+ addPasskey: unavailable,
45
+ deletePasskey: unavailable,
46
+ listUserPasskeys: unavailable,
47
+ signInPasskey: unavailable,
48
+ updatePasskey: unavailable
49
+ };
50
+ }
@@ -78,5 +78,50 @@
78
78
  * @returns how many labels were repaired.
79
79
  */
80
80
  export declare function repairDanglingLabels(root: Document | ParentNode): number;
81
+ /**
82
+ * Name composite widgets whose label cannot be repaired with `for`.
83
+ *
84
+ * A `<div role="radiogroup">` is not labelable, so the `for` repair above skips it and leaves
85
+ * the group with no accessible name at all — the group's caption stays in the tree as loose
86
+ * text, and a screen reader announces the radiogroup unnamed. `aria-labelledby` is the correct
87
+ * instrument here: it names the group without touching any item, so it cannot mis-select
88
+ * anything, which makes it strictly safer than the `for` repair.
89
+ *
90
+ * Gated exactly like `for`: one candidate root per field, and only when the label's own `for`
91
+ * does not already resolve.
92
+ *
93
+ * @returns how many group roots were named.
94
+ */
95
+ export declare function repairGroupLabels(root: Document | ParentNode): number;
96
+ /**
97
+ * Re-point `aria-describedby` tokens that hydration left dangling.
98
+ *
99
+ * The SAME divergence that breaks `for` breaks this, for the same reason, and it is arguably
100
+ * worse. `FormField` renders its error / help / description containers with `:id` as a compiled
101
+ * element binding, so those ids ARE in `dynamicProps` and Vue force-patches them to the client
102
+ * value. The matching `aria-describedby` reaches the control through `useFormField().ariaAttrs`
103
+ * inside a `v-bind` spread, which is NOT in `dynamicProps` — so it keeps the server value.
104
+ *
105
+ * The result on exactly the pages this plugin was written for: every validation error, help
106
+ * text and description points at an id that no longer exists. A sighted user sees "Required
107
+ * field"; a screen-reader user gets silence. That is WCAG 3.3.1 / 3.3.3 — the error is visible
108
+ * but never announced on the field it belongs to.
109
+ *
110
+ * Repaired token-wise rather than wholesale, so a describedby token the application wrote and
111
+ * that still resolves is left exactly as it is.
112
+ *
113
+ * @returns how many controls had at least one token repaired.
114
+ */
115
+ export declare function repairDescribedBy(root: Document | ParentNode): number;
116
+ /**
117
+ * Run every repair over `root` and report what each one did.
118
+ *
119
+ * @returns the per-repair counts, so the dev warning can name the mechanism rather than a total.
120
+ */
121
+ export declare function repairFieldAssociations(root: Document | ParentNode): {
122
+ describedBy: number;
123
+ groups: number;
124
+ labels: number;
125
+ };
81
126
  declare const _default: import("nuxt/app").Plugin<Record<string, unknown>> & import("nuxt/app").ObjectPlugin<Record<string, unknown>>;
82
127
  export default _default;
@@ -1,12 +1,27 @@
1
1
  import { defineNuxtPlugin, useRuntimeConfig } from "#imports";
2
2
  const COMPONENT_ROOT = '[data-slot="root"]';
3
3
  const FIELD_LABEL = 'label[data-slot="label"][for]';
4
- const CONTROL = "input, textarea, select";
5
- const NOT_A_USER_CONTROL = '[type="hidden"], [hidden], [aria-hidden="true"], [disabled]';
4
+ const CONTROL = 'input, textarea, select, button[data-slot="base"]';
5
+ const NATIVE_HIDDEN = '[type="hidden"], [hidden]';
6
+ const NOT_IN_A11Y_TREE = '[aria-hidden="true"]';
7
+ const REKA_SUBMIT_PROXY = "[data-hidden]:not([id])";
8
+ const NOT_A_USER_CONTROL = `${NATIVE_HIDDEN}, ${NOT_IN_A11Y_TREE}, ${REKA_SUBMIT_PROXY}`;
9
+ const GROUP_ROOT = '[role="radiogroup"], [role="group"]';
10
+ const DESCRIBING_SLOTS = '[data-slot="error"], [data-slot="help"], [data-slot="description"], [data-slot="hint"]';
11
+ function isVisuallyRemoved(element) {
12
+ const style = element.style;
13
+ return style?.display === "none" || style?.visibility === "hidden";
14
+ }
6
15
  function findControlForLabel(field) {
7
- const eligible = [...field.querySelectorAll(CONTROL)].filter((control) => !control.matches(NOT_A_USER_CONTROL));
16
+ const eligible = [...field.querySelectorAll(CONTROL)].filter((control) => !control.matches(NOT_A_USER_CONTROL) && !isVisuallyRemoved(control));
8
17
  return eligible.length === 1 && eligible[0].id !== "" ? eligible[0] : null;
9
18
  }
19
+ function isSafeToBind(label, control) {
20
+ if (control.labels?.length) {
21
+ return false;
22
+ }
23
+ return label.ownerDocument.getElementById(control.id) === control;
24
+ }
10
25
  export function repairDanglingLabels(root) {
11
26
  let repaired = 0;
12
27
  for (const label of root.querySelectorAll(FIELD_LABEL)) {
@@ -19,14 +34,72 @@ export function repairDanglingLabels(root) {
19
34
  continue;
20
35
  }
21
36
  const control = findControlForLabel(field);
22
- if (control === null || control.id === label.htmlFor) {
37
+ if (control === null || control.id === label.htmlFor || !isSafeToBind(label, control)) {
23
38
  continue;
24
39
  }
25
40
  label.htmlFor = control.id;
41
+ label.dataset.ltLabelRepaired = "";
42
+ repaired += 1;
43
+ }
44
+ return repaired;
45
+ }
46
+ export function repairGroupLabels(root) {
47
+ let repaired = 0;
48
+ for (const label of root.querySelectorAll(FIELD_LABEL)) {
49
+ const field = label.closest(COMPONENT_ROOT);
50
+ if (field === null) {
51
+ continue;
52
+ }
53
+ const target = label.ownerDocument.getElementById(label.htmlFor);
54
+ if (target !== null && field.contains(target)) {
55
+ continue;
56
+ }
57
+ const groups = field.querySelectorAll(GROUP_ROOT);
58
+ if (groups.length !== 1) {
59
+ continue;
60
+ }
61
+ const group = groups[0];
62
+ if (group.hasAttribute("aria-labelledby") || group.hasAttribute("aria-label")) {
63
+ continue;
64
+ }
65
+ if (label.id === "") {
66
+ label.id = `${group.id === "" ? label.htmlFor : group.id}-lt-label`;
67
+ }
68
+ group.setAttribute("aria-labelledby", label.id);
69
+ label.dataset.ltLabelRepaired = "";
26
70
  repaired += 1;
27
71
  }
28
72
  return repaired;
29
73
  }
74
+ export function repairDescribedBy(root) {
75
+ let repaired = 0;
76
+ for (const control of root.querySelectorAll("[aria-describedby]")) {
77
+ const field = control.closest(COMPONENT_ROOT);
78
+ if (field === null) {
79
+ continue;
80
+ }
81
+ const tokens = control.getAttribute("aria-describedby").split(/\s+/).filter(Boolean);
82
+ const dangling = tokens.filter((token) => control.ownerDocument.getElementById(token) === null);
83
+ if (dangling.length === 0) {
84
+ continue;
85
+ }
86
+ const candidates = [...field.querySelectorAll(DESCRIBING_SLOTS)].filter((element) => element.id !== "");
87
+ if (candidates.length !== dangling.length) {
88
+ continue;
89
+ }
90
+ const replacements = new Map(dangling.map((token, index) => [token, candidates[index].id]));
91
+ control.setAttribute("aria-describedby", tokens.map((token) => replacements.get(token) ?? token).join(" "));
92
+ repaired += 1;
93
+ }
94
+ return repaired;
95
+ }
96
+ export function repairFieldAssociations(root) {
97
+ return {
98
+ describedBy: repairDescribedBy(root),
99
+ groups: repairGroupLabels(root),
100
+ labels: repairDanglingLabels(root)
101
+ };
102
+ }
30
103
  export default defineNuxtPlugin({
31
104
  // After the app's own plugins, so a page that assigns ids itself has already run.
32
105
  enforce: "post",
@@ -36,30 +109,69 @@ export default defineNuxtPlugin({
36
109
  if (options?.enabled === false) {
37
110
  return;
38
111
  }
39
- const maxRepairMs = options?.maxRepairMs ?? 1500;
112
+ const configured = options?.maxRepairMs ?? 1500;
113
+ const maxRepairMs = Number.isFinite(configured) && configured > 0 ? Math.min(configured, 3e4) : 0;
114
+ const observeDeferred = options?.observeDeferred !== false;
40
115
  let sweeping = null;
116
+ let closing = null;
117
+ let observer = null;
118
+ let warned = false;
41
119
  const sweep = () => {
42
- const repaired = repairDanglingLabels(document);
43
- if (repaired > 0 && import.meta.dev) {
120
+ const { describedBy, groups, labels } = repairFieldAssociations(document);
121
+ if (import.meta.dev && !warned && describedBy + groups + labels > 0) {
122
+ warned = true;
123
+ const names = [...document.querySelectorAll("[data-lt-label-repaired]")].map((label) => label.textContent?.trim()).filter(Boolean);
44
124
  console.warn(
45
- `[@lenne.tech/nuxt-extensions] repaired ${repaired} form label association(s) broken by an SSR/client useId() divergence. This is a stopgap \u2014 see runtime/plugins/form-label-association.client.ts.`
125
+ `[@lenne.tech/nuxt-extensions] repaired ${labels} label association(s), ${groups} group name(s) and ${describedBy} aria-describedby reference(s) broken by an SSR/client useId() divergence${names.length > 0 ? `: ${names.join(", ")}` : ""}. This is a stopgap \u2014 see runtime/plugins/form-label-association.client.ts.`
46
126
  );
47
127
  }
48
128
  };
49
129
  const sweepFor = () => {
50
130
  sweep();
51
- if (maxRepairMs <= 0 || sweeping !== null) {
131
+ if (maxRepairMs <= 0) {
52
132
  return;
53
133
  }
54
- sweeping = setInterval(sweep, 250);
55
- setTimeout(() => {
134
+ if (sweeping === null) {
135
+ sweeping = setInterval(sweep, 250);
136
+ }
137
+ if (closing !== null) {
138
+ clearTimeout(closing);
139
+ }
140
+ closing = setTimeout(() => {
56
141
  if (sweeping !== null) {
57
142
  clearInterval(sweeping);
58
143
  sweeping = null;
59
144
  }
145
+ closing = null;
60
146
  }, maxRepairMs);
61
147
  };
62
- nuxtApp.hook("app:mounted", sweepFor);
148
+ const observeDeferredHydration = () => {
149
+ if (!observeDeferred || observer !== null || typeof MutationObserver === "undefined") {
150
+ return;
151
+ }
152
+ let queued = false;
153
+ observer = new MutationObserver((records) => {
154
+ if (queued) {
155
+ return;
156
+ }
157
+ const carriesLabel = records.some(
158
+ (record) => [...record.addedNodes].some((node) => node instanceof Element && (node.matches(FIELD_LABEL) || node.querySelector(FIELD_LABEL) !== null))
159
+ );
160
+ if (!carriesLabel) {
161
+ return;
162
+ }
163
+ queued = true;
164
+ requestAnimationFrame(() => {
165
+ queued = false;
166
+ sweep();
167
+ });
168
+ });
169
+ observer.observe(document.body, { childList: true, subtree: true });
170
+ };
171
+ nuxtApp.hook("app:mounted", () => {
172
+ sweepFor();
173
+ observeDeferredHydration();
174
+ });
63
175
  nuxtApp.hook("page:finish", () => {
64
176
  requestAnimationFrame(sweepFor);
65
177
  });
@@ -26,7 +26,8 @@ export default defineNuxtPlugin({
26
26
  if (options?.enabled === false) {
27
27
  return;
28
28
  }
29
- const maxRestoreMs = options?.maxRestoreMs ?? 1500;
29
+ const configuredRestoreMs = options?.maxRestoreMs ?? 1500;
30
+ const maxRestoreMs = Number.isFinite(configuredRestoreMs) && configuredRestoreMs > 0 ? Math.min(configuredRestoreMs, 3e4) : 0;
30
31
  let edited = /* @__PURE__ */ new Map();
31
32
  nuxtApp.hook("app:beforeMount", () => {
32
33
  edited = collectEditedFields(document);
@@ -179,13 +179,30 @@ export interface LtFormLabelAssociationOptions {
179
179
  enabled?: boolean;
180
180
  /**
181
181
  * How long after mount to keep repairing, in milliseconds. Defaults to `1500`; `0` sweeps
182
- * once and stops.
182
+ * once and stops. Values above 30000 are clamped, and a non-finite value is treated as `0` —
183
+ * a delay past 2^31 overflows `setTimeout` and fires immediately, silently turning an
184
+ * over-large window into no window at all.
183
185
  *
184
- * Most fields are repaired in the first pass. The window exists for server-rendered
185
- * subtrees whose hydration is deferred — `hydrate-on-visible`, an unresolved `<Suspense>`,
186
- * an island — which carry the same divergence but hydrate after `app:mounted`.
186
+ * Most fields are repaired in the first pass. The window exists for server-rendered subtrees
187
+ * whose hydration is deferred — an unresolved `<Suspense>`, an island — which carry the same
188
+ * divergence but hydrate after `app:mounted`. It does NOT reach `hydrate-on-visible` or
189
+ * `hydrate-on-interaction`, which can fire minutes later; `observeDeferred` covers those.
187
190
  */
188
191
  maxRepairMs?: number;
192
+ /**
193
+ * Keep watching for server-rendered subtrees that hydrate after the window closes.
194
+ * Defaults to `true`.
195
+ *
196
+ * `hydrate-on-visible` fires on scroll and `hydrate-on-interaction` on a click — either can
197
+ * be long after mount, and both carry the very same divergence, so no fixed window reaches
198
+ * them. A `MutationObserver` does, and it costs nothing while nothing changes: it reacts
199
+ * only to added subtrees that actually contain a field label, and coalesces a burst into one
200
+ * sweep per frame.
201
+ *
202
+ * Turn it off if your application renders no lazily hydrated server content and you would
203
+ * rather not carry an observer at all; the bounded window then remains the only mechanism.
204
+ */
205
+ observeDeferred?: boolean;
189
206
  }
190
207
  /**
191
208
  * Pre-hydration input preservation.
@@ -275,6 +292,7 @@ export interface LtExtensionsPublicRuntimeConfig {
275
292
  formLabelAssociation: {
276
293
  enabled: boolean;
277
294
  maxRepairMs: number;
295
+ observeDeferred: boolean;
278
296
  };
279
297
  preHydrationInput: {
280
298
  enabled: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lenne.tech/nuxt-extensions",
3
- "version": "1.13.0",
3
+ "version": "1.15.0",
4
4
  "description": "Reusable Nuxt 4 composables, components, and Better-Auth integration for lenne.tech projects",
5
5
  "repository": {
6
6
  "type": "git",
@@ -78,9 +78,9 @@
78
78
  "@nuxt/kit": "^4.0.0"
79
79
  },
80
80
  "peerDependencies": {
81
- "@better-auth/passkey": ">=1.0.0",
81
+ "@better-auth/passkey": ">=1.7.1 <1.8.0",
82
82
  "@playwright/test": ">=1.0.0",
83
- "better-auth": ">=1.0.0",
83
+ "better-auth": ">=1.7.1 <1.8.0",
84
84
  "nuxt": "^4.0.0",
85
85
  "tus-js-client": ">=4.0.0"
86
86
  },
@@ -102,6 +102,7 @@
102
102
  "@better-auth/passkey": "1.7.1",
103
103
  "@nuxt/module-builder": "1.0.3",
104
104
  "@nuxt/schema": "4.5.2",
105
+ "@nuxt/ui": "4.11.0",
105
106
  "@playwright/test": "1.62.1",
106
107
  "@types/node": "26.2.0",
107
108
  "@vitest/coverage-v8": "4.1.11",
@@ -111,6 +112,7 @@
111
112
  "nuxt": "4.5.2",
112
113
  "oxfmt": "0.64.0",
113
114
  "oxlint": "1.79.0",
115
+ "reka-ui": "2.10.3",
114
116
  "tus-js-client": "4.3.1",
115
117
  "typescript": "5.9.3",
116
118
  "vitest": "4.1.11",