nuxt-state 0.1.0 → 0.2.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/README.md CHANGED
@@ -90,6 +90,8 @@ refs remain computed refs, reactive objects remain reactive, and functions are u
90
90
  - The factory is lazy and runs at first use.
91
91
  - It runs once per Nuxt app instance.
92
92
  - The exact factory result is returned to all callers in that app.
93
+ - The factory runs in a detached Vue effect scope owned by the Nuxt app, so effects and Nuxt
94
+ composables are not disposed with the first consuming component.
93
95
  - A module-local `WeakMap` keys instances by `NuxtApp`, providing request isolation while
94
96
  allowing old application instances to be garbage-collected.
95
97
  - Separate `defineState()` calls have separate closure-owned caches, including calls in
@@ -102,9 +104,9 @@ Expose an async function from synchronous state or use Nuxt's data-fetching APIs
102
104
 
103
105
  ## SSR hydration
104
106
 
105
- v0.1.0 transparently hydrates mutable top-level members returned by the factory when they are
106
- created with `ref()` or `reactive()`. Nuxt injects an internal call-site key at build time; the
107
- developer-facing call remains exactly `defineState(factory)`.
107
+ Since v0.1.0, nuxt-state transparently hydrates mutable top-level members returned by the factory.
108
+ Nuxt injects an internal call-site key at build time; the developer-facing call remains exactly
109
+ `defineState(factory)`.
108
110
 
109
111
  On the server, the module captures the final values after rendering in one namespaced Nuxt
110
112
  payload entry. On the client, it runs the factory normally and patches those values into the
@@ -128,25 +130,71 @@ payload serializer. Functions, readonly computed refs, and plain runtime objects
128
130
  by the factory rather than serialized. Concurrent SSR requests retain separate Nuxt-app
129
131
  registries and cannot share user state.
130
132
 
133
+ Snapshot discovery is intentionally limited to mutable members exposed by the factory. Reactive
134
+ state that must survive SSR hydration currently needs to be exposed from the `defineState`
135
+ factory. A private ref captured only by a computed value or function is not visible to the
136
+ snapshot layer; if it is mutated during SSR, its client value can differ and cause a hydration
137
+ mismatch. Discovering closure-private Vue state would require a new explicit API, compiler-level
138
+ analysis, or undocumented reactivity inspection, none of which belongs in v0.2.0.
139
+
131
140
  `useFetch()` can remain inside a synchronous state factory. Its request caching and payload
132
141
  hydration still belong to Nuxt; `nuxt-state` neither replaces nor triggers a second fetch
133
142
  mechanism. If multiple sibling SSR components must all render completed data, await the returned
134
143
  Nuxt `AsyncData` promise in a parent/page as you would with normal Nuxt data fetching.
135
144
 
145
+ When a returned `useFetch().data` ref is snapshotted, both Nuxt's data payload and the internal
146
+ state snapshot refer to it. Nuxt's graph serializer preserves the shared object identity, so the
147
+ response body is emitted once rather than copied into the HTML twice. There is still a small
148
+ snapshot-metadata overhead.
149
+
150
+ ## Compatibility
151
+
152
+ ### Supported
153
+
154
+ - `ref()` and `reactive()`, including nested serializable objects and arrays;
155
+ - `shallowRef()` and `shallowReactive()` with their shallow semantics preserved;
156
+ - `Date`, `Map`, `Set`, shared references, and cyclic graphs supported by Nuxt's payload
157
+ serializer;
158
+ - readonly computed chains and functions as client-recreated runtime state;
159
+ - readonly views when their mutable source is also returned and hydrated;
160
+ - `useFetch()`, `useAsyncData()`, `callOnce()`, `useCookie()`, `useRuntimeConfig()`, `useRoute()`,
161
+ and `useRouter()` in valid Nuxt contexts;
162
+ - first use from plugins, route middleware, layouts, pages, and components;
163
+ - state lifetime across client navigation and repeated component mount/unmount.
164
+
165
+ `useFetch` and `useAsyncData` continue to own their request/payload behavior. nuxt-state's
166
+ snapshot metadata points at the same payload graph rather than serializing response bodies again.
167
+ `callOnce({ mode: 'navigation' })` retains Nuxt's normal per-navigation behavior.
168
+
169
+ ### Characterized or limited
170
+
171
+ - A readonly view is not independent mutable state. If its mutable source is private, it follows
172
+ the private-state limitation below.
173
+ - Writable computed refs are not supported hydration state. Vue exposes no public `isComputed`
174
+ check, and a writable computed currently looks like a mutable ref; restoring it invokes its
175
+ setter and may cause side effects. Return and hydrate its source refs instead.
176
+ - Mutable reactive values that must survive SSR hydration need to be reachable through the
177
+ enumerable object returned from the factory.
178
+ - The intended return is a composable-style object. Arbitrary ref, reactive-root, function, or
179
+ primitive returns still share per app, but the current member-based snapshot format does not
180
+ hydrate them.
181
+ - Cycles are supported for Nuxt-serializable object graphs, not arbitrary native resources or
182
+ custom class instances.
183
+
136
184
  ## Current limitations
137
185
 
138
186
  - Nuxt 4 and Vue 3 only.
139
187
  - Synchronous factories only.
140
188
  - No persistence or browser-storage integration.
141
- - Hydration is guaranteed for standard `ref()` and `reactive()` members only. Advanced
142
- primitives such as shallow/custom refs and writable-computed edge cases are not v0.1.0
143
- guarantees.
189
+ - Hydration is guaranteed for standard and shallow refs/reactives. `customRef()` and writable
190
+ computed hydration are not supported.
144
191
  - Hydrated values must be serializable by Nuxt's payload system; DOM nodes, sockets, functions
145
192
  inside refs, symbols, and arbitrary native/class resources are unsupported.
193
+ - Closure-private mutable state is not discoverable; return any ref/reactive value whose SSR
194
+ mutations must hydrate.
146
195
  - No Nuxt Layers support yet.
147
196
  - State resets when its module is hot-reloaded; HMR preservation is not implemented.
148
- - Compatibility with every context-sensitive Nuxt composable inside a factory is not
149
- guaranteed yet.
197
+ - Context-sensitive composables must still be called while normal Nuxt context is available.
150
198
  - There is no reset API, keyed/multi-instance state, DevTools integration, or central
151
199
  user-facing registry.
152
200
  - The keyed transform is source-sensitive. The supported path is the module's auto-imported
@@ -167,6 +215,7 @@ pnpm fmt:check
167
215
  pnpm lint
168
216
  pnpm test:types
169
217
  pnpm test
218
+ pnpm test:stress
170
219
  pnpm prepack
171
220
  pnpm dev:build
172
221
  ```
package/dist/module.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "nuxt": "^4.0.0"
5
5
  },
6
6
  "configKey": "nuxt-state",
7
- "version": "0.1.0",
7
+ "version": "0.2.0",
8
8
  "builder": {
9
9
  "@nuxt/module-builder": "1.0.3",
10
10
  "unbuild": "3.6.1"
@@ -1,4 +1,5 @@
1
1
  import { useNuxtApp } from "#app";
2
+ import { effectScope } from "vue";
2
3
  import { registerHydratableState } from "../state-registry.js";
3
4
  import { restoreState, snapshotState } from "../state-snapshot.js";
4
5
  function isPromiseLike(value) {
@@ -11,17 +12,28 @@ export function defineState(factory, internalKey) {
11
12
  if (instances.has(nuxtApp)) {
12
13
  return instances.get(nuxtApp);
13
14
  }
14
- const instance = factory();
15
+ const scope = effectScope(true);
16
+ let instance;
17
+ try {
18
+ instance = scope.run(factory);
19
+ } catch (error) {
20
+ scope.stop();
21
+ throw error;
22
+ }
15
23
  if (isPromiseLike(instance)) {
24
+ scope.stop();
16
25
  throw new TypeError(
17
26
  "[nuxt-state] State factories must be synchronous. Expose an async function from the state or use Nuxt data-fetching composables instead."
18
27
  );
19
28
  }
20
29
  instances.set(nuxtApp, instance);
30
+ const app = nuxtApp;
31
+ app.vueApp?.onUnmount?.(() => scope.stop());
21
32
  if (internalKey) {
22
33
  registerHydratableState(nuxtApp, internalKey, {
23
34
  snapshot: () => snapshotState(instance),
24
- restore: (snapshot) => restoreState(instance, snapshot)
35
+ restore: (snapshot) => restoreState(instance, snapshot),
36
+ dispose: () => scope.stop()
25
37
  });
26
38
  }
27
39
  return instance;
@@ -1,5 +1,6 @@
1
1
  import { defineNuxtPlugin, useHydration } from "#app";
2
2
  import {
3
+ clearPendingStateSnapshots,
3
4
  collectStateSnapshots,
4
5
  receiveStateSnapshots
5
6
  } from "../state-registry.js";
@@ -10,4 +11,7 @@ export default defineNuxtPlugin((nuxtApp) => {
10
11
  () => collectStateSnapshots(nuxtApp),
11
12
  (snapshots) => receiveStateSnapshots(nuxtApp, snapshots)
12
13
  );
14
+ if (import.meta.client) {
15
+ nuxtApp.hook("app:mounted", () => clearPendingStateSnapshots(nuxtApp));
16
+ }
13
17
  });
@@ -2,6 +2,7 @@ export type StateHydrationPayload = Record<string, unknown>;
2
2
  export interface HydratableStateEntry {
3
3
  snapshot: () => unknown;
4
4
  restore: (snapshot: unknown) => void;
5
+ dispose?: () => void;
5
6
  }
6
7
  interface StateRegistry {
7
8
  active: Map<string, HydratableStateEntry>;
@@ -11,4 +12,5 @@ export declare function getStateRegistry(nuxtApp: object): StateRegistry;
11
12
  export declare function registerHydratableState(nuxtApp: object, key: string, entry: HydratableStateEntry): void;
12
13
  export declare function collectStateSnapshots(nuxtApp: object): StateHydrationPayload;
13
14
  export declare function receiveStateSnapshots(nuxtApp: object, snapshots: StateHydrationPayload | undefined): void;
15
+ export declare function clearPendingStateSnapshots(nuxtApp: object): void;
14
16
  export {};
@@ -12,6 +12,7 @@ export function getStateRegistry(nuxtApp) {
12
12
  }
13
13
  export function registerHydratableState(nuxtApp, key, entry) {
14
14
  const registry = getStateRegistry(nuxtApp);
15
+ registry.active.get(key)?.dispose?.();
15
16
  registry.active.set(key, entry);
16
17
  if (registry.hydration.has(key)) {
17
18
  const snapshot = registry.hydration.get(key);
@@ -36,3 +37,6 @@ export function receiveStateSnapshots(nuxtApp, snapshots) {
36
37
  entry.restore(snapshot);
37
38
  }
38
39
  }
40
+ export function clearPendingStateSnapshots(nuxtApp) {
41
+ getStateRegistry(nuxtApp).hydration.clear();
42
+ }
@@ -27,44 +27,48 @@ function isPlainRecord(value) {
27
27
  return Object.prototype.toString.call(value) === "[object Object]";
28
28
  }
29
29
  function canPatch(target, source) {
30
- return Array.isArray(target) && Array.isArray(source) || isPlainRecord(target) && isPlainRecord(source);
30
+ return Array.isArray(target) && Array.isArray(source) || isPlainRecord(target) && !isRef(target) && isPlainRecord(source) && !isRef(source);
31
31
  }
32
- function patchValue(target, source) {
32
+ function isTrackableObject(value) {
33
+ return typeof value === "object" && value !== null;
34
+ }
35
+ function patchValue(target, source, seen) {
36
+ if (!isTrackableObject(source)) return source;
37
+ if (seen.has(source)) return seen.get(source);
38
+ if (!canPatch(target, source)) {
39
+ seen.set(source, source);
40
+ return source;
41
+ }
42
+ seen.set(source, target);
33
43
  if (Array.isArray(target) && Array.isArray(source)) {
34
44
  for (let index = 0; index < source.length; index++) {
35
- if (canPatch(target[index], source[index])) {
36
- patchValue(target[index], source[index]);
37
- } else {
38
- target[index] = source[index];
39
- }
45
+ target[index] = patchValue(target[index], source[index], seen);
40
46
  }
41
47
  target.length = source.length;
42
- return;
48
+ return target;
43
49
  }
44
- if (!isPlainRecord(target) || !isPlainRecord(source)) return;
50
+ if (!isPlainRecord(target) || !isPlainRecord(source)) return source;
45
51
  for (const key of Object.keys(target)) {
46
52
  if (!(key in source)) delete target[key];
47
53
  }
48
54
  for (const [key, value] of Object.entries(source)) {
49
- if (canPatch(target[key], value)) {
50
- patchValue(target[key], value);
51
- } else {
52
- target[key] = value;
53
- }
55
+ target[key] = patchValue(target[key], value, seen);
54
56
  }
57
+ return target;
55
58
  }
56
59
  function isStateSnapshotEntry(value) {
57
60
  return isPlainRecord(value) && (value.type === "ref" || value.type === "reactive") && Object.hasOwn(value, "value");
58
61
  }
59
62
  export function restoreState(state, snapshot) {
60
63
  if (!isObjectLike(state) || !isPlainRecord(snapshot)) return;
64
+ const seen = /* @__PURE__ */ new WeakMap();
61
65
  for (const [name, entry] of Object.entries(snapshot)) {
62
66
  if (!isStateSnapshotEntry(entry)) continue;
63
67
  const target = state[name];
64
68
  if (entry.type === "ref" && isRef(target) && !isReadonly(target)) {
65
69
  target.value = entry.value;
66
70
  } else if (entry.type === "reactive" && isReactive(target) && !isReadonly(target)) {
67
- patchValue(target, entry.value);
71
+ patchValue(target, entry.value, seen);
68
72
  }
69
73
  }
70
74
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nuxt-state",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Define shared Nuxt state using the same Composition API you already use in composables.",
5
5
  "keywords": [
6
6
  "composable",
@@ -77,8 +77,9 @@
77
77
  "fmt:check": "oxfmt --check",
78
78
  "test": "vitest run",
79
79
  "test:browser": "vitest run test/browser",
80
+ "test:stress": "vitest run --config vitest.stress.config.ts",
80
81
  "check": "pnpm run fmt:check && pnpm run lint",
81
82
  "test:watch": "vitest",
82
- "test:types": "nuxt-module-build build && vue-tsc --noEmit && pnpm --dir playground exec vue-tsc --noEmit"
83
+ "test:types": "nuxt-module-build prepare && nuxt-module-build build && vue-tsc --noEmit && pnpm --dir playground exec vue-tsc --noEmit"
83
84
  }
84
85
  }