@lenne.tech/nuxt-extensions 1.11.2 → 1.13.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
@@ -76,6 +76,14 @@ export default defineNuxtConfig({
76
76
  enabled: true,
77
77
  basePath: '/ai', // Must match the nest-server AI controller
78
78
  },
79
+ formLabelAssociation: {
80
+ enabled: true, // repair <label for> broken by an SSR/client useId() divergence
81
+ maxRepairMs: 1500, // how long to keep repairing deferred subtrees
82
+ },
83
+ preHydrationInput: {
84
+ enabled: true, // keep text typed before hydration instead of losing it
85
+ maxRestoreMs: 1500, // how long to keep repairing deferred subtrees
86
+ },
79
87
  },
80
88
  });
81
89
  ```
@@ -171,6 +179,61 @@ export default defineNuxtRouteMiddleware((to) => {
171
179
  });
172
180
  ```
173
181
 
182
+ ## Form Label Repair
183
+
184
+ **The defect:** Nuxt UI's `FormField` derives the label's `for` and the control's `id` from one
185
+ `useId()` call. When `useId()` diverges between server and client, the control's `id` is
186
+ force-patched on hydration (vuejs/core#9083) while the label's `for` — a prop on reka-ui's
187
+ `Label` component — is not. The label keeps the stale value and the association breaks.
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).
192
+
193
+ **What this means when you debug a consuming project:**
194
+
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.
205
+ - This is a **stopgap** for a framework-level divergence. Opt out with
206
+ `ltExtensions.formLabelAssociation.enabled: false`.
207
+
208
+ ## Pre-Hydration Input Preservation
209
+
210
+ **The defect:** until Vue hydrates a server-rendered `<input>`, it carries no framework
211
+ listener. Text typed in that window goes into the DOM node, the `input` event reaches nothing,
212
+ and `v-model`'s mounted hook then overwrites it. The entry is **erased, not delayed**. It is
213
+ load-dependent, so it hides in development.
214
+
215
+ **Vue already fixes most of it.** Since 3.5.41 (vuejs/core#14411) Vue adopts the typed value
216
+ into the model on hydration — but only for `type="text"` and `textarea`. `email`, `password`,
217
+ `tel`, `url`, `search` and `number` are still erased. Upstream tracking: vuejs/core#15210.
218
+
219
+ **What this module adds:** `runtime/plugins/pre-hydration-input.client.ts` snapshots edited
220
+ fields at `app:beforeMount` and restores them at `app:mounted`, dispatching a synthetic
221
+ `input` event so `v-model` adopts the value.
222
+
223
+ **What this means when you debug a consuming project:**
224
+
225
+ - **Do not add a `readonly`-until-mounted guard to inputs.** It was tried and removed: it makes
226
+ the field look usable while silently refusing, suppresses browser autofill (`readonly` is the
227
+ documented technique for exactly that), makes screen readers announce "read only" with no
228
+ announcement when it flips back, and stops mobile browsers raising the on-screen keyboard.
229
+ - A field is treated as edited when `value !== defaultValue`, so server-pre-filled values are
230
+ never restored over a legitimate change made during hydration.
231
+ - Each field is restored at most once; a later programmatic clear is not undone.
232
+ - This is a **stopgap**. When Vue widens its gate, delete the plugin —
233
+ `test/pre-hydration-input.test.ts` pins the current behaviour and will fail on the types Vue
234
+ takes over.
235
+ - Opt out with `ltExtensions.preHydrationInput.enabled: false`.
236
+
174
237
  ## Development Rules
175
238
 
176
239
  1. **ALWAYS read source code** in `dist/runtime/` to understand available composables and components
package/README.md CHANGED
@@ -41,6 +41,8 @@ npm install tus-js-client
41
41
  - **Transition Components** - Ready-to-use Vue transition wrappers
42
42
  - **i18n Support** - English and German translations (works without i18n too)
43
43
  - **Auto-imports** - All composables and components are auto-imported
44
+ - **Pre-hydration input preservation** - text typed before hydration is kept, not erased
45
+ - **Form label repair** - restores `<label for>` associations that hydration breaks
44
46
 
45
47
  ## Environment Variables
46
48
 
@@ -156,6 +158,141 @@ export default defineNuxtConfig({
156
158
  });
157
159
  ```
158
160
 
161
+ ## Form Label Repair
162
+
163
+ Restores `<label for>` associations that hydration breaks. Active by default.
164
+
165
+ ### The problem
166
+
167
+ Nuxt UI's `FormField` derives the label's `for` and the control's `id` from a single
168
+ `useId()` call, so they cannot disagree — unless `useId()` itself returns different values on
169
+ server and client, which happens when the two walk a different number of async boundaries.
170
+
171
+ Measured against `@nuxt/ui` 4.11.x:
172
+
173
+ | | label `for` | control `id` |
174
+ |---|---|---|
175
+ | SSR payload | `v-0-4-2` | `v-0-4-2` |
176
+ | after hydration | `v-0-4-2` | `v-0-1-2` |
177
+
178
+ Vue 3.5.39 did not cause this, it exposed it
179
+ ([vuejs/core#9083](https://github.com/vuejs/core/pull/9083) force-patches an element's
180
+ dynamic props on hydration). The control's `id` is such a prop; the label's `for`, passed
181
+ through reka-ui's `Label` component, is not — so only the label keeps the stale value.
182
+
183
+ ### What it costs when it breaks
184
+
185
+ The control loses its programmatic label. Without a placeholder it has no accessible name at
186
+ all and a screen reader announces "edit text, blank"; **with** a placeholder that becomes the
187
+ name instead, so the visible label is no longer part of it and speech input stops working.
188
+ Clicking the label focuses nothing either — which on a checkbox or radio is the primary hit
189
+ target, not a convenience. Tests using `getByRole('textbox', { name })` stop finding fields.
190
+
191
+ ### What the repair does
192
+
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.
195
+
196
+ **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:
201
+
202
+ | Case | Why |
203
+ |------|-----|
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 |
207
+ | Labels your application wrote | Only Nuxt UI's own field labels (`data-slot="label"`) are touched |
208
+
209
+ `for` is repaired rather than `aria-labelledby` added, because `for` carries **both** the
210
+ accessible name and click-to-focus.
211
+
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.
214
+
215
+ ### Opting out
216
+
217
+ ```ts
218
+ export default defineNuxtConfig({
219
+ ltExtensions: {
220
+ formLabelAssociation: {
221
+ enabled: false,
222
+ // or keep it on and only change how long deferred subtrees are waited for:
223
+ // maxRepairMs: 3000,
224
+ },
225
+ },
226
+ });
227
+ ```
228
+
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.
231
+
232
+ ## Pre-Hydration Input Preservation
233
+
234
+ Text typed before a page has hydrated is kept instead of being silently thrown away.
235
+
236
+ ### The problem
237
+
238
+ Until Vue hydrates a server-rendered `<input>`, the element carries **no framework listener**.
239
+ Text typed in that window is written to the DOM node, the `input` event reaches nothing, and
240
+ `v-model`'s mounted hook then writes the model value back over it. The entry is **erased, not
241
+ delayed** — no error, no toast, nothing persisted.
242
+
243
+ It is load-dependent, so it hides in development and shows up on a cold cache, a slow device
244
+ or a throttled CPU — most often on the login screen, where people type on sight.
245
+
246
+ ### What Vue already does, and where it stops
247
+
248
+ Vue fixed this in **3.5.41** ([vuejs/core#14411](https://github.com/vuejs/core/pull/14411)):
249
+ on hydration it compares the field's live value against what the server rendered and, when
250
+ they differ, adopts the typed value into the model instead of overwriting the node.
251
+
252
+ That adoption is gated on `type="text"` and `textarea`. **Every other type still loses the
253
+ entry** — and those are exactly the fields a sign-in form uses:
254
+
255
+ | Input type | Typed before hydration |
256
+ |------------|------------------------|
257
+ | `text`, `textarea` | kept by Vue itself |
258
+ | `email`, `password`, `tel`, `url`, `search`, `number` | **erased** |
259
+
260
+ Widening the gate is tracked upstream as
261
+ [vuejs/core#15210](https://github.com/vuejs/core/issues/15210) — open, labelled
262
+ `p2-edge-case`, no milestone.
263
+
264
+ ### What this module adds
265
+
266
+ A small client plugin that closes the remaining gap. Just before hydration it reads what is
267
+ in each field; just after, it writes back anything that was overwritten and dispatches a
268
+ synthetic `input` event so `v-model` adopts it.
269
+
270
+ A field counts as edited when its value differs from `defaultValue` — the same test Vue uses,
271
+ so values the server pre-filled are never mistaken for user input. A browser autofill that
272
+ lands before hydration is recovered the same way.
273
+
274
+ Fields stay ordinary editable fields throughout. Nothing is made `readonly`, so autofill,
275
+ screen-reader semantics and the mobile on-screen keyboard are untouched, and the user never
276
+ faces a field that looks usable and silently refuses.
277
+
278
+ **This is a stopgap.** When Vue covers the remaining types, delete it —
279
+ `test/pre-hydration-input.test.ts` pins the current gate and will fail on the types Vue takes
280
+ over, which is the signal.
281
+
282
+ ### Opting out
283
+
284
+ ```ts
285
+ export default defineNuxtConfig({
286
+ ltExtensions: {
287
+ preHydrationInput: {
288
+ enabled: false,
289
+ // or keep it on and only change how long deferred subtrees are waited for:
290
+ // maxRestoreMs: 3000,
291
+ },
292
+ },
293
+ });
294
+ ```
295
+
159
296
  ## Usage
160
297
 
161
298
  ### Authentication
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.11.2";
6
+ declare const version = "1.13.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,9 +1,9 @@
1
1
  {
2
2
  "configKey": "ltExtensions",
3
3
  "name": "@lenne.tech/nuxt-extensions",
4
- "version": "1.11.2",
4
+ "version": "1.13.0",
5
5
  "builder": {
6
- "@nuxt/module-builder": "1.0.2",
6
+ "@nuxt/module-builder": "1.0.3",
7
7
  "unbuild": "3.6.1"
8
8
  }
9
9
  }
package/dist/module.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { defineNuxtModule, createResolver, tryResolveModule, addImports, addComponent, addPlugin, addRouteMiddleware } from '@nuxt/kit';
2
2
 
3
3
  const name = "@lenne.tech/nuxt-extensions";
4
- const version = "1.11.2";
4
+ const version = "1.13.0";
5
5
  const configKey = "ltExtensions";
6
6
  const DEFAULT_LT_AUTH_STATE_COOKIE = "lt-auth-state";
7
7
  const DEFAULT_LT_JWT_TOKEN_COOKIE = "lt-jwt-token";
@@ -36,9 +36,15 @@ const defaultOptions = {
36
36
  enabled: true,
37
37
  defaultLocale: "de"
38
38
  },
39
+ formLabelAssociation: {
40
+ enabled: true
41
+ },
39
42
  i18n: {
40
43
  autoMerge: true
41
44
  },
45
+ preHydrationInput: {
46
+ enabled: true
47
+ },
42
48
  tus: {
43
49
  defaultChunkSize: 5 * 1024 * 1024,
44
50
  // 5MB
@@ -63,7 +69,9 @@ const module$1 = defineNuxtModule({
63
69
  systemSetup: { ...defaultOptions.auth.systemSetup, ...options.auth?.systemSetup }
64
70
  },
65
71
  errorTranslation: { ...defaultOptions.errorTranslation, ...options.errorTranslation },
72
+ formLabelAssociation: { ...defaultOptions.formLabelAssociation, ...options.formLabelAssociation },
66
73
  i18n: { ...defaultOptions.i18n, ...options.i18n },
74
+ preHydrationInput: { ...defaultOptions.preHydrationInput, ...options.preHydrationInput },
67
75
  tus: { ...defaultOptions.tus, ...options.tus }
68
76
  };
69
77
  const passkeyAvailable = !!await tryResolveModule("@better-auth/passkey/client", new URL(import.meta.url));
@@ -120,6 +128,14 @@ const module$1 = defineNuxtModule({
120
128
  enabled: resolvedOptions.errorTranslation?.enabled ?? true,
121
129
  defaultLocale: resolvedOptions.errorTranslation?.defaultLocale || "de"
122
130
  },
131
+ formLabelAssociation: {
132
+ enabled: resolvedOptions.formLabelAssociation?.enabled ?? true,
133
+ maxRepairMs: resolvedOptions.formLabelAssociation?.maxRepairMs ?? 1500
134
+ },
135
+ preHydrationInput: {
136
+ enabled: resolvedOptions.preHydrationInput?.enabled ?? true,
137
+ maxRestoreMs: resolvedOptions.preHydrationInput?.maxRestoreMs ?? 1500
138
+ },
123
139
  tus: {
124
140
  defaultChunkSize: resolvedOptions.tus?.defaultChunkSize || 5 * 1024 * 1024,
125
141
  defaultEndpoint: resolvedOptions.tus?.defaultEndpoint || "/files/upload"
@@ -208,6 +224,12 @@ const module$1 = defineNuxtModule({
208
224
  });
209
225
  });
210
226
  }
227
+ if (resolvedOptions.preHydrationInput.enabled !== false) {
228
+ addPlugin(resolve("./runtime/plugins/pre-hydration-input.client"));
229
+ }
230
+ if (resolvedOptions.formLabelAssociation.enabled !== false) {
231
+ addPlugin(resolve("./runtime/plugins/form-label-association.client"));
232
+ }
211
233
  nuxt.options.build.transpile.push(resolve("./runtime"));
212
234
  nuxt.options.build.transpile.push("tus-js-client");
213
235
  console.log(`[${name}] Module loaded with config:`, {
@@ -284,7 +284,7 @@ export declare function createLtAuthClient(config?: LtAuthClientConfig): {
284
284
  requestPasswordReset: <FetchOptions extends import("better-auth").ClientFetchOption<Partial<{
285
285
  email: string;
286
286
  redirectTo?: string | undefined;
287
- }> & Record<string, any>, Partial<Record<string, any>> & Record<string, any>, Record<string, any> | undefined>>(data_0: import("better-auth").Prettify<{
287
+ }> & Record<string, any>, Partial<Record<string, any>> & Record<string, any>, Record<string, string | undefined> | undefined>>(data_0: import("better-auth").Prettify<{
288
288
  email: string;
289
289
  redirectTo?: string | undefined;
290
290
  } & {
@@ -488,8 +488,9 @@ export declare function createLtAuthClient(config?: LtAuthClientConfig): {
488
488
  scopes?: string[] | undefined;
489
489
  requestSignUp?: boolean | undefined;
490
490
  loginHint?: string | undefined;
491
+ additionalParams?: Record<string, string> | undefined;
491
492
  additionalData?: Record<string, any> | undefined;
492
- }> & Record<string, any>, Partial<Record<string, any>> & Record<string, any>, Record<string, any> | undefined>>(data_0: import("better-auth").Prettify<{
493
+ }> & Record<string, any>, Partial<Record<string, any>> & Record<string, any>, Record<string, string | undefined> | undefined>>(data_0: import("better-auth").Prettify<Omit<{
493
494
  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";
494
495
  callbackURL?: string | undefined;
495
496
  newUserCallbackURL?: string | undefined;
@@ -512,9 +513,13 @@ export declare function createLtAuthClient(config?: LtAuthClientConfig): {
512
513
  scopes?: string[] | undefined;
513
514
  requestSignUp?: boolean | undefined;
514
515
  loginHint?: string | undefined;
516
+ additionalParams?: Record<string, string> | undefined;
515
517
  additionalData?: Record<string, any> | undefined;
516
518
  } & {
517
519
  fetchOptions?: FetchOptions | undefined;
520
+ }, "provider"> & {
521
+ provider: string | (string & {});
522
+ fetchOptions?: FetchOptions | undefined;
518
523
  }>, data_1?: FetchOptions | undefined) => Promise<import("better-auth/client").BetterFetchResponse<{
519
524
  redirect: boolean;
520
525
  url: string;
@@ -546,11 +551,20 @@ export declare function createLtAuthClient(config?: LtAuthClientConfig): {
546
551
  message?: string | undefined;
547
552
  }, FetchOptions["throw"] extends true ? true : false>>;
548
553
  };
549
- signOut: <FetchOptions extends import("better-auth").ClientFetchOption<never, Partial<Record<string, any>> & Record<string, any>, Record<string, any> | undefined>>(data_0?: import("better-auth").Prettify<{
550
- query?: Record<string, any> | undefined;
554
+ signOut: <FetchOptions extends import("better-auth").ClientFetchOption<Partial<{
555
+ callbackURL?: string | undefined;
556
+ disableRedirect?: boolean | undefined;
557
+ state?: string | undefined;
558
+ }> & Record<string, any>, Partial<Record<string, any>> & Record<string, any>, Record<string, string | undefined> | undefined>>(data_0?: import("better-auth").Prettify<{
559
+ callbackURL?: string | undefined;
560
+ disableRedirect?: boolean | undefined;
561
+ state?: string | undefined;
562
+ } & {
551
563
  fetchOptions?: FetchOptions | undefined;
552
564
  }> | undefined, data_1?: FetchOptions | undefined) => Promise<import("better-auth/client").BetterFetchResponse<{
553
565
  success: boolean;
566
+ url: string | undefined;
567
+ redirect: boolean | undefined;
554
568
  }, {
555
569
  code?: string | undefined;
556
570
  message?: string | undefined;
@@ -664,7 +678,7 @@ export declare function createLtAuthClient(config?: LtAuthClientConfig): {
664
678
  verifyEmail: <FetchOptions extends import("better-auth").ClientFetchOption<never, Partial<{
665
679
  token: string;
666
680
  callbackURL?: string | undefined;
667
- }> & Record<string, any>, Record<string, any> | undefined>>(data_0: import("better-auth").Prettify<{
681
+ }> & Record<string, any>, Record<string, string | undefined> | undefined>>(data_0: import("better-auth").Prettify<{
668
682
  query: {
669
683
  token: string;
670
684
  callbackURL?: string | undefined;
@@ -679,7 +693,7 @@ export declare function createLtAuthClient(config?: LtAuthClientConfig): {
679
693
  sendVerificationEmail: <FetchOptions extends import("better-auth").ClientFetchOption<Partial<{
680
694
  email: string;
681
695
  callbackURL?: string | undefined;
682
- }> & Record<string, any>, Partial<Record<string, any>> & Record<string, any>, Record<string, any> | undefined>>(data_0: import("better-auth").Prettify<{
696
+ }> & Record<string, any>, Partial<Record<string, any>> & Record<string, any>, Record<string, string | undefined> | undefined>>(data_0: import("better-auth").Prettify<{
683
697
  email: string;
684
698
  callbackURL?: string | undefined;
685
699
  } & {
@@ -693,7 +707,7 @@ export declare function createLtAuthClient(config?: LtAuthClientConfig): {
693
707
  changeEmail: <FetchOptions extends import("better-auth").ClientFetchOption<Partial<{
694
708
  newEmail: string;
695
709
  callbackURL?: string | undefined;
696
- }> & Record<string, any>, Partial<Record<string, any>> & Record<string, any>, Record<string, any> | undefined>>(data_0: import("better-auth").Prettify<{
710
+ }> & Record<string, any>, Partial<Record<string, any>> & Record<string, any>, Record<string, string | undefined> | undefined>>(data_0: import("better-auth").Prettify<{
697
711
  newEmail: string;
698
712
  callbackURL?: string | undefined;
699
713
  } & {
@@ -704,7 +718,7 @@ export declare function createLtAuthClient(config?: LtAuthClientConfig): {
704
718
  code?: string | undefined;
705
719
  message?: string | undefined;
706
720
  }, FetchOptions["throw"] extends true ? true : false>>;
707
- updateSession: <FetchOptions extends import("better-auth").ClientFetchOption<Partial<Partial<{}>> & Record<string, any>, Partial<Record<string, any>> & Record<string, any>, Record<string, any> | undefined>>(data_0?: import("better-auth").Prettify<import("better-auth/client").InferSessionUpdateCtx<{
721
+ updateSession: <FetchOptions extends import("better-auth").ClientFetchOption<Partial<Partial<{}>> & Record<string, any>, Partial<Record<string, any>> & Record<string, any>, Record<string, string | undefined> | undefined>>(data_0?: import("better-auth").Prettify<import("better-auth/client").InferSessionUpdateCtx<{
708
722
  basePath: string;
709
723
  baseURL: any;
710
724
  fetchOptions: {
@@ -729,7 +743,7 @@ export declare function createLtAuthClient(config?: LtAuthClientConfig): {
729
743
  updateUser: <FetchOptions extends import("better-auth").ClientFetchOption<Partial<Partial<{}> & {
730
744
  name?: string | undefined;
731
745
  image?: string | undefined | null;
732
- }> & Record<string, any>, Partial<Record<string, any>> & Record<string, any>, Record<string, any> | undefined>>(data_0?: import("better-auth").Prettify<import("better-auth/client").InferUserUpdateCtx<{
746
+ }> & Record<string, any>, Partial<Record<string, any>> & Record<string, any>, Record<string, string | undefined> | undefined>>(data_0?: import("better-auth").Prettify<import("better-auth/client").InferUserUpdateCtx<{
733
747
  basePath: string;
734
748
  baseURL: any;
735
749
  fetchOptions: {
@@ -746,7 +760,7 @@ export declare function createLtAuthClient(config?: LtAuthClientConfig): {
746
760
  callbackURL?: string | undefined;
747
761
  password?: string | undefined;
748
762
  token?: string | undefined;
749
- }> & Record<string, any>, Partial<Record<string, any>> & Record<string, any>, Record<string, any> | undefined>>(data_0?: import("better-auth").Prettify<{
763
+ }> & Record<string, any>, Partial<Record<string, any>> & Record<string, any>, Record<string, string | undefined> | undefined>>(data_0?: import("better-auth").Prettify<{
750
764
  callbackURL?: string | undefined;
751
765
  password?: string | undefined;
752
766
  token?: string | undefined;
@@ -762,7 +776,7 @@ export declare function createLtAuthClient(config?: LtAuthClientConfig): {
762
776
  callback: <FetchOptions extends import("better-auth").ClientFetchOption<never, Partial<{
763
777
  token: string;
764
778
  callbackURL?: string | undefined;
765
- }> & Record<string, any>, Record<string, any> | undefined>>(data_0: import("better-auth").Prettify<{
779
+ }> & Record<string, any>, Record<string, string | undefined> | undefined>>(data_0: import("better-auth").Prettify<{
766
780
  query: {
767
781
  token: string;
768
782
  callbackURL?: string | undefined;
@@ -776,7 +790,7 @@ export declare function createLtAuthClient(config?: LtAuthClientConfig): {
776
790
  message?: string | undefined;
777
791
  }, FetchOptions["throw"] extends true ? true : false>>;
778
792
  };
779
- listSessions: <FetchOptions extends import("better-auth").ClientFetchOption<never, Partial<Record<string, any>> & Record<string, any>, Record<string, any> | undefined>>(data_0?: import("better-auth").Prettify<{
793
+ listSessions: <FetchOptions extends import("better-auth").ClientFetchOption<never, Partial<Record<string, any>> & Record<string, any>, Record<string, string | undefined> | undefined>>(data_0?: import("better-auth").Prettify<{
780
794
  query?: Record<string, any> | undefined;
781
795
  fetchOptions?: FetchOptions | undefined;
782
796
  }> | undefined, data_1?: FetchOptions | undefined) => Promise<import("better-auth/client").BetterFetchResponse<import("better-auth").Prettify<{
@@ -794,7 +808,7 @@ export declare function createLtAuthClient(config?: LtAuthClientConfig): {
794
808
  }, FetchOptions["throw"] extends true ? true : false>>;
795
809
  revokeSession: <FetchOptions extends import("better-auth").ClientFetchOption<Partial<{
796
810
  token: string;
797
- }> & Record<string, any>, Partial<Record<string, any>> & Record<string, any>, Record<string, any> | undefined>>(data_0: import("better-auth").Prettify<{
811
+ }> & Record<string, any>, Partial<Record<string, any>> & Record<string, any>, Record<string, string | undefined> | undefined>>(data_0: import("better-auth").Prettify<{
798
812
  token: string;
799
813
  } & {
800
814
  fetchOptions?: FetchOptions | undefined;
@@ -804,7 +818,7 @@ export declare function createLtAuthClient(config?: LtAuthClientConfig): {
804
818
  code?: string | undefined;
805
819
  message?: string | undefined;
806
820
  }, FetchOptions["throw"] extends true ? true : false>>;
807
- revokeSessions: <FetchOptions extends import("better-auth").ClientFetchOption<never, Partial<Record<string, any>> & Record<string, any>, Record<string, any> | undefined>>(data_0?: import("better-auth").Prettify<{
821
+ revokeSessions: <FetchOptions extends import("better-auth").ClientFetchOption<never, Partial<Record<string, any>> & Record<string, any>, Record<string, string | undefined> | undefined>>(data_0?: import("better-auth").Prettify<{
808
822
  query?: Record<string, any> | undefined;
809
823
  fetchOptions?: FetchOptions | undefined;
810
824
  }> | undefined, data_1?: FetchOptions | undefined) => Promise<import("better-auth/client").BetterFetchResponse<{
@@ -813,7 +827,7 @@ export declare function createLtAuthClient(config?: LtAuthClientConfig): {
813
827
  code?: string | undefined;
814
828
  message?: string | undefined;
815
829
  }, FetchOptions["throw"] extends true ? true : false>>;
816
- revokeOtherSessions: <FetchOptions extends import("better-auth").ClientFetchOption<never, Partial<Record<string, any>> & Record<string, any>, Record<string, any> | undefined>>(data_0?: import("better-auth").Prettify<{
830
+ revokeOtherSessions: <FetchOptions extends import("better-auth").ClientFetchOption<never, Partial<Record<string, any>> & Record<string, any>, Record<string, string | undefined> | undefined>>(data_0?: import("better-auth").Prettify<{
817
831
  query?: Record<string, any> | undefined;
818
832
  fetchOptions?: FetchOptions | undefined;
819
833
  }> | undefined, data_1?: FetchOptions | undefined) => Promise<import("better-auth/client").BetterFetchResponse<{
@@ -830,14 +844,15 @@ export declare function createLtAuthClient(config?: LtAuthClientConfig): {
830
844
  nonce?: string | undefined;
831
845
  accessToken?: string | undefined;
832
846
  refreshToken?: string | undefined;
833
- scopes?: string[] | undefined;
834
847
  } | undefined;
835
848
  requestSignUp?: boolean | undefined;
836
849
  scopes?: string[] | undefined;
837
850
  errorCallbackURL?: string | undefined;
838
851
  disableRedirect?: boolean | undefined;
852
+ loginHint?: string | undefined;
853
+ additionalParams?: Record<string, string> | undefined;
839
854
  additionalData?: Record<string, any> | undefined;
840
- }> & Record<string, any>, Partial<Record<string, any>> & Record<string, any>, Record<string, any> | undefined>>(data_0: import("better-auth").Prettify<{
855
+ }> & Record<string, any>, Partial<Record<string, any>> & Record<string, any>, Record<string, string | undefined> | undefined>>(data_0: import("better-auth").Prettify<{
841
856
  provider: unknown;
842
857
  callbackURL?: string | undefined;
843
858
  idToken?: {
@@ -845,12 +860,13 @@ export declare function createLtAuthClient(config?: LtAuthClientConfig): {
845
860
  nonce?: string | undefined;
846
861
  accessToken?: string | undefined;
847
862
  refreshToken?: string | undefined;
848
- scopes?: string[] | undefined;
849
863
  } | undefined;
850
864
  requestSignUp?: boolean | undefined;
851
865
  scopes?: string[] | undefined;
852
866
  errorCallbackURL?: string | undefined;
853
867
  disableRedirect?: boolean | undefined;
868
+ loginHint?: string | undefined;
869
+ additionalParams?: Record<string, string> | undefined;
854
870
  additionalData?: Record<string, any> | undefined;
855
871
  } & {
856
872
  fetchOptions?: FetchOptions | undefined;
@@ -861,7 +877,7 @@ export declare function createLtAuthClient(config?: LtAuthClientConfig): {
861
877
  code?: string | undefined;
862
878
  message?: string | undefined;
863
879
  }, FetchOptions["throw"] extends true ? true : false>>;
864
- listAccounts: <FetchOptions extends import("better-auth").ClientFetchOption<never, Partial<Record<string, any>> & Record<string, any>, Record<string, any> | undefined>>(data_0?: import("better-auth").Prettify<{
880
+ listAccounts: <FetchOptions extends import("better-auth").ClientFetchOption<never, Partial<Record<string, any>> & Record<string, any>, Record<string, string | undefined> | undefined>>(data_0?: import("better-auth").Prettify<{
865
881
  query?: Record<string, any> | undefined;
866
882
  fetchOptions?: FetchOptions | undefined;
867
883
  }> | undefined, data_1?: FetchOptions | undefined) => Promise<import("better-auth/client").BetterFetchResponse<{
@@ -871,17 +887,16 @@ export declare function createLtAuthClient(config?: LtAuthClientConfig): {
871
887
  updatedAt: Date;
872
888
  userId: string;
873
889
  providerId: string;
890
+ issuer: string;
874
891
  accountId: string;
875
892
  }[], {
876
893
  code?: string | undefined;
877
894
  message?: string | undefined;
878
895
  }, FetchOptions["throw"] extends true ? true : false>>;
879
896
  unlinkAccount: <FetchOptions extends import("better-auth").ClientFetchOption<Partial<{
880
- providerId: string;
881
- accountId?: string | undefined;
882
- }> & Record<string, any>, Partial<Record<string, any>> & Record<string, any>, Record<string, any> | undefined>>(data_0: import("better-auth").Prettify<{
883
- providerId: string;
884
- accountId?: string | undefined;
897
+ accountId: string;
898
+ }> & Record<string, any>, Partial<Record<string, any>> & Record<string, any>, Record<string, string | undefined> | undefined>>(data_0: import("better-auth").Prettify<{
899
+ accountId: string;
885
900
  } & {
886
901
  fetchOptions?: FetchOptions | undefined;
887
902
  }>, data_1?: FetchOptions | undefined) => Promise<import("better-auth/client").BetterFetchResponse<{
@@ -891,12 +906,18 @@ export declare function createLtAuthClient(config?: LtAuthClientConfig): {
891
906
  message?: string | undefined;
892
907
  }, FetchOptions["throw"] extends true ? true : false>>;
893
908
  refreshToken: <FetchOptions extends import("better-auth").ClientFetchOption<Partial<{
894
- providerId: string;
895
- accountId?: string | undefined;
909
+ accountId: string;
896
910
  userId?: string | undefined;
897
- }> & Record<string, any>, Partial<Record<string, any>> & Record<string, any>, Record<string, any> | undefined>>(data_0: import("better-auth").Prettify<{
898
- providerId: string;
899
- accountId?: string | undefined;
911
+ } | {
912
+ useAccountCookie: true;
913
+ userId?: string | undefined;
914
+ }> & Record<string, any>, Partial<Record<string, any>> & Record<string, any>, Record<string, string | undefined> | undefined>>(data_0: import("better-auth").Prettify<{
915
+ accountId: string;
916
+ userId?: string | undefined;
917
+ } & {
918
+ fetchOptions?: FetchOptions | undefined;
919
+ }> | import("better-auth").Prettify<{
920
+ useAccountCookie: true;
900
921
  userId?: string | undefined;
901
922
  } & {
902
923
  fetchOptions?: FetchOptions | undefined;
@@ -914,12 +935,18 @@ export declare function createLtAuthClient(config?: LtAuthClientConfig): {
914
935
  message?: string | undefined;
915
936
  }, FetchOptions["throw"] extends true ? true : false>>;
916
937
  getAccessToken: <FetchOptions extends import("better-auth").ClientFetchOption<Partial<{
917
- providerId: string;
918
- accountId?: string | undefined;
938
+ accountId: string;
919
939
  userId?: string | undefined;
920
- }> & Record<string, any>, Partial<Record<string, any>> & Record<string, any>, Record<string, any> | undefined>>(data_0: import("better-auth").Prettify<{
921
- providerId: string;
922
- accountId?: string | undefined;
940
+ } | {
941
+ useAccountCookie: true;
942
+ userId?: string | undefined;
943
+ }> & Record<string, any>, Partial<Record<string, any>> & Record<string, any>, Record<string, string | undefined> | undefined>>(data_0: import("better-auth").Prettify<{
944
+ accountId: string;
945
+ userId?: string | undefined;
946
+ } & {
947
+ fetchOptions?: FetchOptions | undefined;
948
+ }> | import("better-auth").Prettify<{
949
+ useAccountCookie: true;
923
950
  userId?: string | undefined;
924
951
  } & {
925
952
  fetchOptions?: FetchOptions | undefined;
@@ -933,19 +960,29 @@ export declare function createLtAuthClient(config?: LtAuthClientConfig): {
933
960
  message?: string | undefined;
934
961
  }, FetchOptions["throw"] extends true ? true : false>>;
935
962
  accountInfo: <FetchOptions extends import("better-auth").ClientFetchOption<never, Partial<{
936
- accountId?: string | undefined;
937
- providerId?: string | undefined;
963
+ accountId: string;
938
964
  userId?: string | undefined;
939
- }> & Record<string, any>, Record<string, any> | undefined>>(data_0?: import("better-auth").Prettify<{
940
- query?: {
941
- accountId?: string | undefined;
942
- providerId?: string | undefined;
965
+ } | {
966
+ useAccountCookie: true;
967
+ userId?: string | undefined;
968
+ }> & Record<string, any>, Record<string, string | undefined> | undefined>>(data_0: import("better-auth").Prettify<{
969
+ query: {
970
+ accountId: string;
943
971
  userId?: string | undefined;
944
- } | undefined;
972
+ } | {
973
+ useAccountCookie: true;
974
+ userId?: string | undefined;
975
+ };
945
976
  fetchOptions?: FetchOptions | undefined;
946
- }> | undefined, data_1?: FetchOptions | undefined) => Promise<import("better-auth/client").BetterFetchResponse<{
977
+ }>, data_1?: FetchOptions | undefined) => Promise<import("better-auth/client").BetterFetchResponse<{
978
+ account: {
979
+ id: string;
980
+ providerId: string;
981
+ issuer: string;
982
+ accountId: string;
983
+ };
947
984
  user: import("better-auth").OAuth2UserInfo;
948
- data: Record<string, any>;
985
+ data: object;
949
986
  }, {
950
987
  code?: string | undefined;
951
988
  message?: string | undefined;
@@ -953,7 +990,7 @@ export declare function createLtAuthClient(config?: LtAuthClientConfig): {
953
990
  getSession: <FetchOptions extends import("better-auth").ClientFetchOption<never, Partial<{
954
991
  disableCookieCache?: unknown;
955
992
  disableRefresh?: unknown;
956
- }> & Record<string, any>, Record<string, any> | undefined>>(data_0?: import("better-auth").Prettify<{
993
+ }> & Record<string, any>, Record<string, string | undefined> | undefined>>(data_0?: import("better-auth").Prettify<{
957
994
  query?: {
958
995
  disableCookieCache?: unknown;
959
996
  disableRefresh?: unknown;
@@ -983,6 +1020,27 @@ export declare function createLtAuthClient(config?: LtAuthClientConfig): {
983
1020
  code?: string | undefined;
984
1021
  message?: string | undefined;
985
1022
  }, FetchOptions["throw"] extends true ? true : false>>;
1023
+ hydrateSession: (session: {
1024
+ user: import("better-auth").StripEmptyObjects<{
1025
+ id: string;
1026
+ createdAt: Date;
1027
+ updatedAt: Date;
1028
+ email: string;
1029
+ emailVerified: boolean;
1030
+ name: string;
1031
+ image?: string | null | undefined;
1032
+ }>;
1033
+ session: import("better-auth").StripEmptyObjects<{
1034
+ id: string;
1035
+ createdAt: Date;
1036
+ updatedAt: Date;
1037
+ userId: string;
1038
+ expiresAt: Date;
1039
+ token: string;
1040
+ ipAddress?: string | null | undefined;
1041
+ userAgent?: string | null | undefined;
1042
+ }>;
1043
+ } | null) => void;
986
1044
  $ERROR_CODES: {
987
1045
  USER_NOT_FOUND: {
988
1046
  readonly code: "USER_NOT_FOUND";
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Repair `<label for>` associations that hydration leaves pointing at nothing.
3
+ *
4
+ * ## The defect
5
+ *
6
+ * Nuxt UI's `FormField` generates ONE id with `useId()` and uses it for both halves of
7
+ * the association: `<Label :for="id">` and, via `provide`/`inject`, the control's `id`.
8
+ * One source, so the two cannot disagree — unless `useId()` itself returns different
9
+ * values on the server and on the client.
10
+ *
11
+ * It does. Measured on a Nuxt 4 app page (SVL, DEV-3093), against `@nuxt/ui` 4.11.x:
12
+ *
13
+ * | | label `for` | control `id` |
14
+ * | -- | -- | -- |
15
+ * | SSR payload | `v-0-4-2` | `v-0-4-2` |
16
+ * | after hydration | `v-0-4-2` | `v-0-1-2` |
17
+ *
18
+ * The middle segment is Vue's per-branch id prefix, which is extended at every async
19
+ * boundary. Server and client walked a different number of them, so the ids diverge.
20
+ *
21
+ * **Vue >= 3.5.39 did not cause this — it exposed it.** "force patch dynamic props when
22
+ * hydrating" (vuejs/core#9083, merged 2026-06-25) makes a hydrated element adopt its
23
+ * freshly computed client value, where previously the server value simply stayed.
24
+ *
25
+ * Note WHY only the label is left behind, because it points at the real fix: the control's
26
+ * `id` is a compiled element binding (`<input :id="id">`), so it is in the element's
27
+ * `dynamicProps` and gets force-patched. The label's `for` is a PROP on reka-ui's `Label`
28
+ * component and arrives at the `<label>` through `$attrs`, which is not part of that set.
29
+ * The asymmetry is the component boundary, not the id generation. Fixing the divergence
30
+ * upstream — or having `FormField` pass an SSR-stable id — removes the need for this file.
31
+ *
32
+ * ## Why it is worth a plugin
33
+ *
34
+ * A control whose label does not resolve loses its programmatic label: the visible text is
35
+ * no longer its accessible name (WCAG 1.3.1). Where the field has no placeholder it has no
36
+ * accessible name at all and a screen reader announces "edit text, blank" (4.1.2). Where it
37
+ * has one, the placeholder becomes the name instead — which is arguably worse, because the
38
+ * name no longer contains the visible label and speech input stops working (2.5.3 Label in
39
+ * Name). Clicking the label also no longer focuses the control, and for a checkbox or radio
40
+ * that is not a convenience but the primary hit target.
41
+ *
42
+ * And any test that addresses fields the way assistive technology does
43
+ * (`getByRole('textbox', { name })`, the recommended Playwright locator) stops finding
44
+ * them, which is how this surfaced: 39 specs at once.
45
+ *
46
+ * ## What this does, and what it deliberately does not
47
+ *
48
+ * After mount — and for a bounded window afterwards — every Nuxt UI field label whose
49
+ * target does not resolve is re-pointed at the control inside its own field.
50
+ *
51
+ * It **refuses to guess**, which is the rule that matters most here. A repair that fires
52
+ * where it should not is worse than the defect: a dangling label is inert and visible, a
53
+ * mis-pointed one is confidently wrong and silent, and on a radio or checkbox a click on
54
+ * the caption would then change a value the user never chose. So the repair only happens
55
+ * when the field contains exactly ONE eligible control. A group of radios or checkboxes
56
+ * shares one field root and is therefore skipped, not guessed at.
57
+ *
58
+ * `for` is repaired rather than `aria-labelledby` added, because `for` carries BOTH the
59
+ * accessible name and click-to-focus. Adding `aria-labelledby` would restore the name
60
+ * and leave the label dead to the mouse.
61
+ *
62
+ * This is a repair, not a cure: the right fix is for the id not to diverge in the first
63
+ * place (see the component-boundary note above). It is applied here because the divergence
64
+ * sits in the framework stack rather than in any one application, and because a nameless
65
+ * form is not something to leave in place while that is resolved upstream.
66
+ *
67
+ * Default on; opt out with `ltExtensions.formLabelAssociation.enabled: false`.
68
+ */
69
+ /**
70
+ * Re-point every dangling field label at the control in its own field.
71
+ *
72
+ * Exported for the unit test: it runs against a DOM fixture rather than a live app, so the
73
+ * pairing rules stay verifiable without a browser. Not part of the package's public API.
74
+ *
75
+ * @param root Where to look. Ids are resolved against the owning document regardless, since
76
+ * `id` is document-scoped — so a scoped root narrows which labels are visited, not which
77
+ * ids count as resolvable.
78
+ * @returns how many labels were repaired.
79
+ */
80
+ export declare function repairDanglingLabels(root: Document | ParentNode): number;
81
+ declare const _default: import("nuxt/app").Plugin<Record<string, unknown>> & import("nuxt/app").ObjectPlugin<Record<string, unknown>>;
82
+ export default _default;
@@ -0,0 +1,67 @@
1
+ import { defineNuxtPlugin, useRuntimeConfig } from "#imports";
2
+ const COMPONENT_ROOT = '[data-slot="root"]';
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]';
6
+ function findControlForLabel(field) {
7
+ const eligible = [...field.querySelectorAll(CONTROL)].filter((control) => !control.matches(NOT_A_USER_CONTROL));
8
+ return eligible.length === 1 && eligible[0].id !== "" ? eligible[0] : null;
9
+ }
10
+ export function repairDanglingLabels(root) {
11
+ let repaired = 0;
12
+ for (const label of root.querySelectorAll(FIELD_LABEL)) {
13
+ const field = label.closest(COMPONENT_ROOT);
14
+ if (field === null) {
15
+ continue;
16
+ }
17
+ const target = label.ownerDocument.getElementById(label.htmlFor);
18
+ if (target !== null && field.contains(target)) {
19
+ continue;
20
+ }
21
+ const control = findControlForLabel(field);
22
+ if (control === null || control.id === label.htmlFor) {
23
+ continue;
24
+ }
25
+ label.htmlFor = control.id;
26
+ repaired += 1;
27
+ }
28
+ return repaired;
29
+ }
30
+ export default defineNuxtPlugin({
31
+ // After the app's own plugins, so a page that assigns ids itself has already run.
32
+ enforce: "post",
33
+ name: "lt-form-label-association",
34
+ setup(nuxtApp) {
35
+ const options = useRuntimeConfig().public.ltExtensions?.formLabelAssociation;
36
+ if (options?.enabled === false) {
37
+ return;
38
+ }
39
+ const maxRepairMs = options?.maxRepairMs ?? 1500;
40
+ let sweeping = null;
41
+ const sweep = () => {
42
+ const repaired = repairDanglingLabels(document);
43
+ if (repaired > 0 && import.meta.dev) {
44
+ 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.`
46
+ );
47
+ }
48
+ };
49
+ const sweepFor = () => {
50
+ sweep();
51
+ if (maxRepairMs <= 0 || sweeping !== null) {
52
+ return;
53
+ }
54
+ sweeping = setInterval(sweep, 250);
55
+ setTimeout(() => {
56
+ if (sweeping !== null) {
57
+ clearInterval(sweeping);
58
+ sweeping = null;
59
+ }
60
+ }, maxRepairMs);
61
+ };
62
+ nuxtApp.hook("app:mounted", sweepFor);
63
+ nuxtApp.hook("page:finish", () => {
64
+ requestAnimationFrame(sweepFor);
65
+ });
66
+ }
67
+ });
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Pre-hydration input preservation — the part Vue does not do yet.
3
+ *
4
+ * ## What Vue already handles
5
+ *
6
+ * Until Vue hydrates a server-rendered `<input>`, the element carries no framework listener.
7
+ * What the user types is written to the DOM node, the `input` event reaches no model, and
8
+ * `v-model`'s mounted hook then writes the model value back over it — the entry is ERASED,
9
+ * not delayed.
10
+ *
11
+ * Vue fixed this in `vModelText` (vuejs/core#14411, shipped in 3.5.41): `created` records
12
+ * `el.defaultValue`, and `mounted` compares the live value against it. When they differ, the
13
+ * DOM value is pushed INTO the model instead of the node being overwritten.
14
+ *
15
+ * That adoption is gated on `el.type === 'text' || el.type === 'textarea'`. **Every other
16
+ * input type still falls through to the erasure** — `email`, `password`, `tel`, `url`,
17
+ * `search`, `number`. Verified against Vue 3.5.41 in `test/pre-hydration-input.test.ts`.
18
+ *
19
+ * Widening the gate is tracked upstream as vuejs/core#15210 (open, `p2-edge-case`, no
20
+ * milestone, PR #15211 pending). **This plugin is a stopgap for exactly that gap and should
21
+ * be deleted once the upstream fix lands** — the test above will start failing on the types
22
+ * Vue has taken over, which is the signal to remove it.
23
+ *
24
+ * ## Why this matters more than the gate suggests
25
+ *
26
+ * The uncovered types are precisely the ones a sign-in form uses. That is also where people
27
+ * type on sight, and where losing the entry is most expensive: a password silently truncated
28
+ * to its last few characters produces "invalid credentials", which sends the user down the
29
+ * wrong diagnostic path entirely.
30
+ *
31
+ * ## How it works
32
+ *
33
+ * At `app:beforeMount` the client bundle has loaded but hydration has not run, so every
34
+ * server-rendered field still holds whatever the user typed. A field is treated as edited
35
+ * when its live value differs from `defaultValue` — the same test Vue itself uses, so the
36
+ * server's own pre-filled values are never mistaken for user input.
37
+ *
38
+ * At `app:mounted` any field whose value was overwritten gets it back, followed by a
39
+ * synthetic `input` event so the framework's own listener adopts it. Setting `.value` alone
40
+ * would not do: the model would still hold the old value and the next render would wipe the
41
+ * node again.
42
+ *
43
+ * A browser autofill that landed before hydration is recovered the same way.
44
+ *
45
+ * ## Why not make the field `readonly` until mounted
46
+ *
47
+ * Declining the keystroke was the first approach tried and it is worse. The user is left with
48
+ * a field that looks usable and silently refuses, which reads as a broken page. It also costs
49
+ * real things: `readonly` is the documented technique for SUPPRESSING browser autofill,
50
+ * screen readers announce "read only" and never announce the silent flip back, and mobile
51
+ * browsers do not raise the on-screen keyboard for it. Preserving the entry avoids all of
52
+ * that, because the field is never anything other than a normal, editable field.
53
+ */
54
+ type EditableField = HTMLInputElement | HTMLTextAreaElement;
55
+ /**
56
+ * Collect fields the user edited before hydration.
57
+ *
58
+ * `value !== defaultValue` is the discriminator, matching Vue's own: `defaultValue` is what
59
+ * the server rendered, so a difference can only have come from the user (or from an autofill,
60
+ * which is just as worth keeping). Server-supplied values therefore never enter the snapshot
61
+ * and can never be restored over a legitimate change made during hydration.
62
+ */
63
+ export declare function collectEditedFields(root: ParentNode): Map<EditableField, string>;
64
+ /**
65
+ * Write a preserved value back onto a field and tell the framework about it.
66
+ *
67
+ * @returns `true` when a value was actually restored.
68
+ */
69
+ export declare function restoreField(field: EditableField, value: string): boolean;
70
+ declare const _default: import("nuxt/app").Plugin<Record<string, unknown>> & import("nuxt/app").ObjectPlugin<Record<string, unknown>>;
71
+ export default _default;
@@ -0,0 +1,57 @@
1
+ import { defineNuxtPlugin, useRuntimeConfig } from "#imports";
2
+ export function collectEditedFields(root) {
3
+ const edited = /* @__PURE__ */ new Map();
4
+ for (const node of root.querySelectorAll("input, textarea")) {
5
+ const field = node;
6
+ if (field.value !== field.defaultValue) {
7
+ edited.set(field, field.value);
8
+ }
9
+ }
10
+ return edited;
11
+ }
12
+ export function restoreField(field, value) {
13
+ if (!field.isConnected || field.value === value) {
14
+ return false;
15
+ }
16
+ field.value = value;
17
+ field.dispatchEvent(new Event("input", { bubbles: true }));
18
+ return true;
19
+ }
20
+ export default defineNuxtPlugin({
21
+ // After the app's own plugins: the restore has to land once hydration has done its writes.
22
+ enforce: "post",
23
+ name: "lt-pre-hydration-input",
24
+ setup(nuxtApp) {
25
+ const options = useRuntimeConfig().public.ltExtensions?.preHydrationInput;
26
+ if (options?.enabled === false) {
27
+ return;
28
+ }
29
+ const maxRestoreMs = options?.maxRestoreMs ?? 1500;
30
+ let edited = /* @__PURE__ */ new Map();
31
+ nuxtApp.hook("app:beforeMount", () => {
32
+ edited = collectEditedFields(document);
33
+ });
34
+ nuxtApp.hook("app:mounted", () => {
35
+ if (edited.size === 0) {
36
+ return;
37
+ }
38
+ const sweep = () => {
39
+ for (const [field, value] of edited) {
40
+ if (restoreField(field, value)) {
41
+ edited.delete(field);
42
+ }
43
+ }
44
+ };
45
+ sweep();
46
+ if (maxRestoreMs <= 0) {
47
+ edited = /* @__PURE__ */ new Map();
48
+ return;
49
+ }
50
+ const interval = setInterval(sweep, 250);
51
+ setTimeout(() => {
52
+ clearInterval(interval);
53
+ edited = /* @__PURE__ */ new Map();
54
+ }, maxRestoreMs);
55
+ });
56
+ }
57
+ });
@@ -1,5 +1,5 @@
1
1
  export type { LtAuthClientConfig, LtAuthMode, LtAuthResponse, LtAuthState, LtPasskeyAuthResult, LtPasskeyRegisterResult, LtUser, UseLtAuthReturn } from './auth.js';
2
2
  export type { LtFileInfo, LtUploadItem, LtUploadOptions, LtUploadProgress, LtUploadStatus, UseLtFileReturn, UseLtTusUploadReturn } from './upload.js';
3
- export type { LtAiModuleOptions, LtAuthCookieNamesOptions, LtAuthModuleOptions, LtErrorTranslationModuleOptions, LtExtensionsModuleOptions, LtExtensionsPublicRuntimeConfig, LtI18nModuleOptions, LtSystemSetupModuleOptions, LtTusModuleOptions, } from './module.js';
3
+ export type { LtAiModuleOptions, LtAuthCookieNamesOptions, LtAuthModuleOptions, LtErrorTranslationModuleOptions, LtExtensionsModuleOptions, LtExtensionsPublicRuntimeConfig, LtFormLabelAssociationOptions, LtI18nModuleOptions, LtPreHydrationInputOptions, LtSystemSetupModuleOptions, LtTusModuleOptions, } from './module.js';
4
4
  export type { LtErrorTranslationResponse, LtParsedError, UseLtErrorTranslationReturn } from './error.js';
5
5
  export type { LtAiAction, LtAiAvailableConnection, LtAiBudgetLimit, LtAiBudgetSummary, LtAiConnection, LtAiConnectionInput, LtAiConnectionPreference, LtAiEffectiveSlot, LtAiInteraction, LtAiMessage, LtAiMode, LtAiPlaceholder, LtAiPrompt, LtAiPromptHint, LtAiPromptHintInput, LtAiPromptInput, LtAiPromptRunInput, LtAiResponse, LtAiSlot, LtAiSlotInput, LtAiStreamEvent, LtAiStreamHandlers, LtAiUsage, LtAiUsageInfo, LtAiUsageScope, UseLtAiAdminReturn, UseLtAiChatOptions, UseLtAiChatReturn, UseLtAiConnectionsReturn, UseLtAiPlaceholdersReturn, UseLtAiPromptsReturn, UseLtAiReturn, UseLtAiUsageReturn, } from './ai.js';
@@ -127,11 +127,116 @@ export interface LtExtensionsModuleOptions {
127
127
  auth?: LtAuthModuleOptions;
128
128
  /** Error translation configuration */
129
129
  errorTranslation?: LtErrorTranslationModuleOptions;
130
+ /** Form label association repair configuration */
131
+ formLabelAssociation?: LtFormLabelAssociationOptions;
130
132
  /** i18n configuration */
131
133
  i18n?: LtI18nModuleOptions;
134
+ /** Pre-hydration input preservation configuration */
135
+ preHydrationInput?: LtPreHydrationInputOptions;
132
136
  /** TUS upload module configuration */
133
137
  tus?: LtTusModuleOptions;
134
138
  }
139
+ /**
140
+ * Repair of `<label for>` associations broken by an SSR/client `useId()` divergence.
141
+ *
142
+ * Nuxt UI's `FormField` derives the label's `for` and the control's `id` from ONE `useId()`
143
+ * call, so they cannot disagree — unless `useId()` itself returns different values on server
144
+ * and client, which it does whenever the two walk a different number of async boundaries.
145
+ * The label then keeps the server value while the control adopts the client one.
146
+ *
147
+ * The control loses its programmatic label. Without a placeholder it has no accessible name
148
+ * at all and a screen reader announces "edit text, blank"; with one, the placeholder becomes
149
+ * the name instead, so the visible label is no longer part of it and speech input stops
150
+ * working. Either way clicking the label focuses nothing — which on a checkbox or radio is
151
+ * the primary hit target, not a convenience.
152
+ *
153
+ * Vue >= 3.5.39 did not cause this, it exposed it: vuejs/core#9083 force-patches an element's
154
+ * dynamic props during hydration, and the control's `id` is one while the label's `for` —
155
+ * passed through reka-ui's `Label` component and `$attrs` — is not.
156
+ *
157
+ * The repair refuses to guess: it only fires when the field contains exactly one eligible
158
+ * control, so a radio or checkbox group is skipped rather than having every caption bound to
159
+ * its first option.
160
+ *
161
+ * @example
162
+ * ```ts
163
+ * export default defineNuxtConfig({
164
+ * ltExtensions: {
165
+ * formLabelAssociation: { enabled: false },
166
+ * },
167
+ * });
168
+ * ```
169
+ */
170
+ export interface LtFormLabelAssociationOptions {
171
+ /**
172
+ * Enable the repair. Defaults to `true`.
173
+ *
174
+ * Turn it off only with a reason. Two real ones: an application that assigns `for` itself
175
+ * and depends on those exact values, or a test suite asserting on literal `for` / `id`
176
+ * strings rather than on the accessible name. The alternative is a form whose fields carry
177
+ * no programmatic label.
178
+ */
179
+ enabled?: boolean;
180
+ /**
181
+ * How long after mount to keep repairing, in milliseconds. Defaults to `1500`; `0` sweeps
182
+ * once and stops.
183
+ *
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`.
187
+ */
188
+ maxRepairMs?: number;
189
+ }
190
+ /**
191
+ * Pre-hydration input preservation.
192
+ *
193
+ * Until Vue hydrates a server-rendered `<input>`, the element carries no framework listener:
194
+ * text typed into it reaches no model and is then overwritten by the mounting value — ERASED,
195
+ * not delayed.
196
+ *
197
+ * Vue fixed this in 3.5.41 (vuejs/core#14411), but only for `type="text"` and `textarea`.
198
+ * Every other type still loses the entry: `email`, `password`, `tel`, `url`, `search`,
199
+ * `number` — precisely the fields a sign-in form uses, and where people are most likely to
200
+ * type on sight. Widening it is tracked upstream as vuejs/core#15210.
201
+ *
202
+ * This closes that remaining gap: what the user typed is read out of the DOM just before
203
+ * hydration and written back afterwards, with a synthetic `input` event so `v-model` adopts
204
+ * it. Fields stay ordinary editable fields throughout — nothing is made `readonly`, so
205
+ * autofill, screen-reader semantics and the mobile keyboard are untouched. A browser autofill
206
+ * that lands before hydration is recovered the same way.
207
+ *
208
+ * Applies to every `<input>` and `<textarea>` on the page, regardless of which UI library
209
+ * rendered it. It is a stopgap and is meant to be removed once Vue covers the remaining
210
+ * types.
211
+ *
212
+ * @example
213
+ * ```ts
214
+ * export default defineNuxtConfig({
215
+ * ltExtensions: {
216
+ * preHydrationInput: { enabled: false },
217
+ * },
218
+ * });
219
+ * ```
220
+ */
221
+ export interface LtPreHydrationInputOptions {
222
+ /**
223
+ * Enable preservation. Defaults to `true`.
224
+ *
225
+ * Turn it off only with a reason: the alternative is that a user who types immediately
226
+ * after a page appears loses the entry silently.
227
+ */
228
+ enabled?: boolean;
229
+ /**
230
+ * How long after mount to keep restoring, in milliseconds. Defaults to `1500`; `0` restores
231
+ * once and stops immediately.
232
+ *
233
+ * Most fields are repaired in the first pass. The window exists for subtrees whose mount is
234
+ * deferred — an unresolved `<Suspense>` boundary writes its model value later than
235
+ * `app:mounted`. When it elapses the snapshot is dropped, so preserved values, passwords
236
+ * included, are not kept alive for the session.
237
+ */
238
+ maxRestoreMs?: number;
239
+ }
135
240
  /**
136
241
  * Public runtime config added by this module
137
242
  */
@@ -167,6 +272,14 @@ export interface LtExtensionsPublicRuntimeConfig {
167
272
  enabled: boolean;
168
273
  defaultLocale: string;
169
274
  };
275
+ formLabelAssociation: {
276
+ enabled: boolean;
277
+ maxRepairMs: number;
278
+ };
279
+ preHydrationInput: {
280
+ enabled: boolean;
281
+ maxRestoreMs: number;
282
+ };
170
283
  tus: {
171
284
  defaultChunkSize: number;
172
285
  defaultEndpoint: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lenne.tech/nuxt-extensions",
3
- "version": "1.11.2",
3
+ "version": "1.13.0",
4
4
  "description": "Reusable Nuxt 4 composables, components, and Better-Auth integration for lenne.tech projects",
5
5
  "repository": {
6
6
  "type": "git",
@@ -99,21 +99,22 @@
99
99
  }
100
100
  },
101
101
  "devDependencies": {
102
- "@better-auth/passkey": "1.6.23",
103
- "@nuxt/module-builder": "1.0.2",
102
+ "@better-auth/passkey": "1.7.1",
103
+ "@nuxt/module-builder": "1.0.3",
104
104
  "@nuxt/schema": "4.5.2",
105
- "@playwright/test": "1.61.1",
106
- "@types/node": "26.1.1",
107
- "@vitest/coverage-v8": "4.1.10",
108
- "better-auth": "1.6.23",
109
- "happy-dom": "20.10.6",
105
+ "@playwright/test": "1.62.1",
106
+ "@types/node": "26.2.0",
107
+ "@vitest/coverage-v8": "4.1.11",
108
+ "@vue/server-renderer": "3.5.41",
109
+ "better-auth": "1.7.1",
110
+ "happy-dom": "20.11.6",
110
111
  "nuxt": "4.5.2",
111
- "oxfmt": "0.59.0",
112
- "oxlint": "1.74.0",
112
+ "oxfmt": "0.64.0",
113
+ "oxlint": "1.79.0",
113
114
  "tus-js-client": "4.3.1",
114
115
  "typescript": "5.9.3",
115
- "vitest": "4.1.10",
116
- "vue-tsc": "3.3.7"
116
+ "vitest": "4.1.11",
117
+ "vue-tsc": "3.3.11"
117
118
  },
118
119
  "keywords": [
119
120
  "nuxt",