nuxt-state 0.1.0 → 0.3.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
@@ -83,6 +83,62 @@ Different server requests and different client apps receive different objects.
83
83
  The returned object is not cloned, wrapped, or transformed. Refs remain refs, computed
84
84
  refs remain computed refs, reactive objects remain reactive, and functions are unchanged.
85
85
 
86
+ ## Nuxt Layers
87
+
88
+ Reusable Nuxt Layers can provide states from their resolved application directory:
89
+
90
+ ```text
91
+ layers/
92
+ └── admin/
93
+ ├── nuxt.config.ts
94
+ └── app/
95
+ └── states/
96
+ └── permissions.ts
97
+ ```
98
+
99
+ ```ts
100
+ // layers/admin/app/states/permissions.ts
101
+ export const usePermissions = defineState(() => {
102
+ const roles = ref(['reader'])
103
+ return { roles }
104
+ })
105
+ ```
106
+
107
+ `usePermissions()` is auto-imported in the consuming application. Local auto-discovered layers,
108
+ layers listed in `extends`, package-provided layers, nested state directories, hydration, and
109
+ request isolation use the same behavior as project states.
110
+
111
+ nuxt-state follows Nuxt's normal layer priority. The project wins over every extended layer;
112
+ higher-priority layers win over lower-priority layers when exports collide. No aliases or custom
113
+ override API are generated.
114
+
115
+ ## DevTools
116
+
117
+ Enable Nuxt DevTools normally:
118
+
119
+ ```ts
120
+ export default defineNuxtConfig({
121
+ devtools: {
122
+ enabled: true,
123
+ },
124
+ })
125
+ ```
126
+
127
+ In development, the **Nuxt State** tab provides a read-only inspector. It shows active state
128
+ instances, internal hydration keys, Hydrated/Client-only status, member classifications, bounded
129
+ value previews, and a separate list of statically discovered states with project-relative source
130
+ and layer origin.
131
+
132
+ Opening the panel never executes a state factory. Values are queried while the view is visible;
133
+ there are no permanent deep watchers. Functions are descriptors and cannot be invoked, large
134
+ values are truncated, and cyclic/shared graphs use reference markers. v0.3.0 does not support
135
+ editing, patching, resetting, deleting, or disposing application state.
136
+
137
+ Nuxt DevTools 3.4's stable iframe integration is used. No alpha/nightly override is required.
138
+ Runtime hydration hashes cannot currently be mapped safely back to export names without another
139
+ compiler transform, so active cards use `State $<internal-key>` while the separate Known states
140
+ list shows names, sources, and layer origins. The internal key is debug information, not API.
141
+
86
142
  ## Semantics
87
143
 
88
144
  - The factory is synchronous and takes no arguments.
@@ -90,6 +146,8 @@ refs remain computed refs, reactive objects remain reactive, and functions are u
90
146
  - The factory is lazy and runs at first use.
91
147
  - It runs once per Nuxt app instance.
92
148
  - The exact factory result is returned to all callers in that app.
149
+ - The factory runs in a detached Vue effect scope owned by the Nuxt app, so effects and Nuxt
150
+ composables are not disposed with the first consuming component.
93
151
  - A module-local `WeakMap` keys instances by `NuxtApp`, providing request isolation while
94
152
  allowing old application instances to be garbage-collected.
95
153
  - Separate `defineState()` calls have separate closure-owned caches, including calls in
@@ -102,9 +160,9 @@ Expose an async function from synchronous state or use Nuxt's data-fetching APIs
102
160
 
103
161
  ## SSR hydration
104
162
 
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)`.
163
+ Since v0.1.0, nuxt-state transparently hydrates mutable top-level members returned by the factory.
164
+ Nuxt injects an internal call-site key at build time; the developer-facing call remains exactly
165
+ `defineState(factory)`.
108
166
 
109
167
  On the server, the module captures the final values after rendering in one namespaced Nuxt
110
168
  payload entry. On the client, it runs the factory normally and patches those values into the
@@ -128,27 +186,74 @@ payload serializer. Functions, readonly computed refs, and plain runtime objects
128
186
  by the factory rather than serialized. Concurrent SSR requests retain separate Nuxt-app
129
187
  registries and cannot share user state.
130
188
 
189
+ Snapshot discovery is intentionally limited to mutable members exposed by the factory. Reactive
190
+ state that must survive SSR hydration currently needs to be exposed from the `defineState`
191
+ factory. A private ref captured only by a computed value or function is not visible to the
192
+ snapshot layer; if it is mutated during SSR, its client value can differ and cause a hydration
193
+ mismatch. Discovering closure-private Vue state would require a new explicit API, compiler-level
194
+ analysis, or undocumented reactivity inspection, none of which belongs in v0.2.0.
195
+
131
196
  `useFetch()` can remain inside a synchronous state factory. Its request caching and payload
132
197
  hydration still belong to Nuxt; `nuxt-state` neither replaces nor triggers a second fetch
133
198
  mechanism. If multiple sibling SSR components must all render completed data, await the returned
134
199
  Nuxt `AsyncData` promise in a parent/page as you would with normal Nuxt data fetching.
135
200
 
201
+ When a returned `useFetch().data` ref is snapshotted, both Nuxt's data payload and the internal
202
+ state snapshot refer to it. Nuxt's graph serializer preserves the shared object identity, so the
203
+ response body is emitted once rather than copied into the HTML twice. There is still a small
204
+ snapshot-metadata overhead.
205
+
206
+ ## Compatibility
207
+
208
+ ### Supported
209
+
210
+ - `ref()` and `reactive()`, including nested serializable objects and arrays;
211
+ - `shallowRef()` and `shallowReactive()` with their shallow semantics preserved;
212
+ - `Date`, `Map`, `Set`, shared references, and cyclic graphs supported by Nuxt's payload
213
+ serializer;
214
+ - readonly computed chains and functions as client-recreated runtime state;
215
+ - readonly views when their mutable source is also returned and hydrated;
216
+ - `useFetch()`, `useAsyncData()`, `callOnce()`, `useCookie()`, `useRuntimeConfig()`, `useRoute()`,
217
+ and `useRouter()` in valid Nuxt contexts;
218
+ - project, local, explicit-extends, and package-provided Nuxt Layer states with native priority;
219
+ - first use from plugins, route middleware, layouts, pages, and components;
220
+ - state lifetime across client navigation and repeated component mount/unmount.
221
+
222
+ `useFetch` and `useAsyncData` continue to own their request/payload behavior. nuxt-state's
223
+ snapshot metadata points at the same payload graph rather than serializing response bodies again.
224
+ `callOnce({ mode: 'navigation' })` retains Nuxt's normal per-navigation behavior.
225
+
226
+ ### Characterized or limited
227
+
228
+ - A readonly view is not independent mutable state. If its mutable source is private, it follows
229
+ the private-state limitation below.
230
+ - Writable computed refs are not supported hydration state. Vue exposes no public `isComputed`
231
+ check, and a writable computed currently looks like a mutable ref; restoring it invokes its
232
+ setter and may cause side effects. Return and hydrate its source refs instead.
233
+ - Mutable reactive values that must survive SSR hydration need to be reachable through the
234
+ enumerable object returned from the factory.
235
+ - The intended return is a composable-style object. Arbitrary ref, reactive-root, function, or
236
+ primitive returns still share per app, but the current member-based snapshot format does not
237
+ hydrate them.
238
+ - Cycles are supported for Nuxt-serializable object graphs, not arbitrary native resources or
239
+ custom class instances.
240
+
136
241
  ## Current limitations
137
242
 
138
243
  - Nuxt 4 and Vue 3 only.
139
244
  - Synchronous factories only.
140
245
  - 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.
246
+ - Hydration is guaranteed for standard and shallow refs/reactives. `customRef()` and writable
247
+ computed hydration are not supported.
144
248
  - Hydrated values must be serializable by Nuxt's payload system; DOM nodes, sockets, functions
145
249
  inside refs, symbols, and arbitrary native/class resources are unsupported.
146
- - No Nuxt Layers support yet.
250
+ - Closure-private mutable state is not discoverable; return any ref/reactive value whose SSR
251
+ mutations must hydrate.
147
252
  - 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.
150
- - There is no reset API, keyed/multi-instance state, DevTools integration, or central
151
- user-facing registry.
253
+ - Context-sensitive composables must still be called while normal Nuxt context is available.
254
+ - DevTools is read-only and development-only. Active hashes are not safely correlated with
255
+ static export names; there is no public or process-global state registry.
256
+ - There is no reset API or keyed/multi-instance state.
152
257
  - The keyed transform is source-sensitive. The supported path is the module's auto-imported
153
258
  `defineState`; a barrel re-export or unrelated manual wrapper is not guaranteed to receive an
154
259
  internal hydration key.
@@ -167,6 +272,7 @@ pnpm fmt:check
167
272
  pnpm lint
168
273
  pnpm test:types
169
274
  pnpm test
275
+ pnpm test:stress
170
276
  pnpm prepack
171
277
  pnpm dev:build
172
278
  ```
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.3.0",
8
8
  "builder": {
9
9
  "@nuxt/module-builder": "1.0.3",
10
10
  "unbuild": "3.6.1"
package/dist/module.mjs CHANGED
@@ -1,5 +1,22 @@
1
- import { resolve } from 'node:path';
2
- import { defineNuxtModule, createResolver, addImports, addPlugin, addImportsDir } from '@nuxt/kit';
1
+ import { resolve, sep, relative, basename } from 'node:path';
2
+ import { defineNuxtModule, createResolver, addImports, addPlugin, getLayerDirectories, addTemplate, addDevServerHandler, addImportsDir } from '@nuxt/kit';
3
+ import { addCustomTab } from '@nuxt/devtools-kit';
4
+
5
+ function registerDevtools(route, nuxt) {
6
+ addCustomTab(
7
+ {
8
+ name: "nuxt-state",
9
+ title: "Nuxt State",
10
+ icon: "carbon:data-vis-4",
11
+ category: "app",
12
+ view: {
13
+ type: "iframe",
14
+ src: route
15
+ }
16
+ },
17
+ nuxt
18
+ );
19
+ }
3
20
 
4
21
  const module$1 = defineNuxtModule({
5
22
  meta: {
@@ -22,8 +39,147 @@ const module$1 = defineNuxtModule({
22
39
  argumentLength: 2
23
40
  });
24
41
  addPlugin(resolver.resolve("./runtime/app/plugins/hydration"));
25
- addImportsDir(resolve(nuxt.options.srcDir, "states/**"));
42
+ const devtools = nuxt.options.devtools;
43
+ const devtoolsEnabled = nuxt.options.dev && devtools !== false && !(typeof devtools === "object" && devtools.enabled === false);
44
+ const layers = getLayerDirectories(nuxt);
45
+ const stateDirectories = layers.map((layer) => resolve(layer.app, "states"));
46
+ if (devtoolsEnabled) {
47
+ const devtoolsRoute = "/__nuxt_state_devtools__/";
48
+ let knownStates = [];
49
+ nuxt.hook("imports:extend", (imports) => {
50
+ const winners = /* @__PURE__ */ new Map();
51
+ for (const item of imports) {
52
+ const layerIndex = stateDirectories.findIndex(
53
+ (directory) => item.from === directory || item.from.startsWith(`${directory}${sep}`)
54
+ );
55
+ const name = item.as || item.name;
56
+ if (layerIndex < 0 || winners.has(name)) continue;
57
+ const layer = layers[layerIndex];
58
+ const projectRelative = relative(nuxt.options.rootDir, item.from);
59
+ const source = projectRelative.startsWith("..") ? basename(item.from) : projectRelative;
60
+ const layerRelative = relative(nuxt.options.rootDir, layer.root).replace(/\/$/, "");
61
+ winners.set(name, {
62
+ name,
63
+ source,
64
+ origin: layerIndex === 0 ? "Project" : layerRelative || basename(layer.root)
65
+ });
66
+ }
67
+ knownStates = [...winners.values()].sort((a, b) => a.name.localeCompare(b.name));
68
+ });
69
+ addTemplate({
70
+ filename: "nuxt-state/metadata.mjs",
71
+ getContents: () => `export default ${JSON.stringify(knownStates)}`
72
+ });
73
+ registerDevtools(devtoolsRoute, nuxt);
74
+ addPlugin(resolver.resolve("./runtime/app/plugins/devtools.client"));
75
+ addDevServerHandler({
76
+ route: devtoolsRoute,
77
+ handler(event) {
78
+ event.node.res.setHeader("content-type", "text/html; charset=utf-8");
79
+ return renderDevtoolsView();
80
+ }
81
+ });
82
+ }
83
+ addImportsDir(layers.map((layer) => resolve(layer.app, "states/**")));
26
84
  }
27
85
  });
86
+ function renderDevtoolsView() {
87
+ return `<!doctype html>
88
+ <html lang="en">
89
+ <head>
90
+ <meta charset="utf-8">
91
+ <meta name="viewport" content="width=device-width,initial-scale=1">
92
+ <title>Nuxt State</title>
93
+ <style>
94
+ :root { color-scheme: light dark; font: 14px/1.5 ui-sans-serif, system-ui, sans-serif; }
95
+ * { box-sizing: border-box; }
96
+ body { margin: 0; color: #d9e1ea; background: #101418; }
97
+ header { position: sticky; top: 0; z-index: 1; display: flex; gap: 12px; align-items: center; padding: 14px 18px; border-bottom: 1px solid #2a343e; background: #101418ee; }
98
+ h1 { margin: 0 auto 0 0; font-size: 18px; }
99
+ input, button { border: 1px solid #34414d; border-radius: 7px; background: #182027; color: inherit; padding: 7px 10px; }
100
+ input { width: min(280px, 42vw); }
101
+ button { cursor: pointer; }
102
+ main { display: grid; gap: 12px; padding: 16px; }
103
+ article { border: 1px solid #2a343e; border-radius: 10px; background: #151b21; overflow: hidden; }
104
+ article > div { padding: 12px 14px; }
105
+ h2 { margin: 0; font-size: 15px; }
106
+ .meta { color: #94a3b1; font-size: 12px; overflow-wrap: anywhere; }
107
+ table { width: 100%; border-collapse: collapse; }
108
+ th, td { padding: 8px 14px; border-top: 1px solid #27313a; text-align: left; vertical-align: top; }
109
+ th { color: #91a0ad; font-size: 11px; text-transform: uppercase; }
110
+ td:nth-child(1) { width: 22%; font-weight: 600; }
111
+ td:nth-child(2) { width: 16%; color: #65d8a5; }
112
+ pre { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; font: 12px/1.45 ui-monospace, monospace; }
113
+ .empty, .error { padding: 36px; text-align: center; color: #91a0ad; }
114
+ .badge { display: inline-block; margin-left: 7px; padding: 1px 6px; border-radius: 999px; background: #263a32; color: #7ee2ad; font-size: 11px; }
115
+ @media (prefers-color-scheme: light) {
116
+ body { color: #202832; background: #f7f9fb; }
117
+ header { border-color: #d9e0e6; background: #f7f9fbee; }
118
+ input, button, article { border-color: #d7dfe6; background: white; }
119
+ th, td { border-color: #e3e8ed; }
120
+ .badge { background: #dff7ea; color: #176b43; }
121
+ }
122
+ </style>
123
+ </head>
124
+ <body>
125
+ <header>
126
+ <h1>Nuxt State <span class="badge">Read only</span></h1>
127
+ <input id="filter" type="search" placeholder="Filter active states">
128
+ <button id="refresh" type="button">Refresh</button>
129
+ </header>
130
+ <main id="states"><p class="empty">Connecting to the Nuxt app\u2026</p></main>
131
+ <script>
132
+ const root = document.querySelector('#states')
133
+ const filter = document.querySelector('#filter')
134
+ let activeStates = []
135
+ let knownStates = []
136
+ let timer
137
+ let visible = true
138
+ let refreshing = false
139
+
140
+ const escapeHTML = value => String(value).replace(/[&<>"']/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[char])
141
+ const render = () => {
142
+ const query = filter.value.trim().toLowerCase()
143
+ const shown = activeStates.filter(state => (state.name + ' ' + state.key).toLowerCase().includes(query))
144
+ const known = knownStates.filter(state => (state.name + ' ' + state.source + ' ' + state.origin).toLowerCase().includes(query))
145
+ const activeHTML = shown.length
146
+ ? shown.map(state => '<article><div><h2>' + escapeHTML(state.name) + '<span class="badge">' + escapeHTML(state.hydration) + '</span></h2><div class="meta">Internal hydration key: ' + escapeHTML(state.key) + '</div></div><table><thead><tr><th>Member</th><th>Kind</th><th>Value preview</th></tr></thead><tbody>' + state.members.map(member => '<tr><td>' + escapeHTML(member.name) + '</td><td>' + escapeHTML(member.kind) + '</td><td><pre>' + escapeHTML(JSON.stringify(member.value, null, 2)) + '</pre></td></tr>').join('') + '</tbody></table></article>').join('')
147
+ : '<p class="empty">' + (activeStates.length ? 'No matching active states.' : 'No active states yet. Opening this panel does not instantiate lazy state.') + '</p>'
148
+ const knownHTML = known.length
149
+ ? '<article><div><h2>Known states</h2><div class="meta">Discovered statically; factories remain lazy.</div></div><table><thead><tr><th>Name</th><th>Origin</th><th>Source</th></tr></thead><tbody>' + known.map(state => '<tr><td>' + escapeHTML(state.name) + '</td><td>' + escapeHTML(state.origin) + '</td><td><pre>' + escapeHTML(state.source) + '</pre></td></tr>').join('') + '</tbody></table></article>'
150
+ : ''
151
+ root.innerHTML = activeHTML + knownHTML
152
+ }
153
+ const refresh = async () => {
154
+ if (!visible || refreshing) return
155
+ refreshing = true
156
+ try {
157
+ const client = window.__NUXT_DEVTOOLS__
158
+ if (!client?.host?.nuxt) throw new Error('Nuxt DevTools host is not connected.')
159
+ const request = { result: undefined }
160
+ await client.host.nuxt.callHook('nuxt-state:inspect', request)
161
+ activeStates = request.result?.active || []
162
+ knownStates = request.result?.known || []
163
+ render()
164
+ } catch (error) {
165
+ root.innerHTML = '<p class="error">' + escapeHTML(error?.message || error) + '</p>'
166
+ } finally {
167
+ refreshing = false
168
+ }
169
+ }
170
+ const observer = new IntersectionObserver(entries => {
171
+ visible = entries.some(entry => entry.isIntersecting)
172
+ if (visible) refresh()
173
+ })
174
+ observer.observe(document.documentElement)
175
+ filter.addEventListener('input', render)
176
+ document.querySelector('#refresh').addEventListener('click', refresh)
177
+ timer = setInterval(refresh, 1000)
178
+ addEventListener('pagehide', () => { clearInterval(timer); observer.disconnect() }, { once: true })
179
+ refresh()
180
+ <\/script>
181
+ </body>
182
+ </html>`;
183
+ }
28
184
 
29
185
  export { module$1 as default };
@@ -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,18 +12,41 @@ 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
- registerHydratableState(nuxtApp, internalKey, {
33
+ const entry = {
23
34
  snapshot: () => snapshotState(instance),
24
- restore: (snapshot) => restoreState(instance, snapshot)
25
- });
35
+ restore: (snapshot) => restoreState(instance, snapshot),
36
+ dispose: () => scope.stop()
37
+ };
38
+ if (import.meta.dev) {
39
+ entry.debug = {
40
+ state: instance,
41
+ hydration: import.meta.server ? "Server" : "Client-only"
42
+ };
43
+ const restore = entry.restore;
44
+ entry.restore = (snapshot) => {
45
+ restore(snapshot);
46
+ entry.debug.hydration = "Hydrated";
47
+ };
48
+ }
49
+ registerHydratableState(nuxtApp, internalKey, entry);
26
50
  }
27
51
  return instance;
28
52
  };
@@ -0,0 +1,4 @@
1
+ declare module '#build/nuxt-state/metadata' {
2
+ const states: Array<{ name: string; source: string; origin: string }>
3
+ export default states
4
+ }
@@ -0,0 +1,2 @@
1
+ declare const _default: import("nuxt/app").Plugin<Record<string, unknown>> & import("nuxt/app").ObjectPlugin<Record<string, unknown>>;
2
+ export default _default;
@@ -0,0 +1,17 @@
1
+ import { defineNuxtPlugin } from "#app";
2
+ import knownStates from "#build/nuxt-state/metadata";
3
+ import { inspectActiveStates } from "../state-inspector.js";
4
+ export default defineNuxtPlugin({
5
+ name: "nuxt-state:devtools",
6
+ setup(nuxtApp) {
7
+ nuxtApp.hook(
8
+ "nuxt-state:inspect",
9
+ ((request) => {
10
+ request.result = {
11
+ active: inspectActiveStates(nuxtApp),
12
+ known: knownStates
13
+ };
14
+ })
15
+ );
16
+ }
17
+ });
@@ -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
  });
@@ -0,0 +1,16 @@
1
+ export interface StateInspectorMember {
2
+ name: string;
3
+ kind: string;
4
+ value: unknown;
5
+ }
6
+ export interface StateInspectorEntry {
7
+ key: string;
8
+ name: string;
9
+ source?: string;
10
+ origin?: string;
11
+ hydration: 'Hydrated' | 'Client-only' | 'Server';
12
+ members: StateInspectorMember[];
13
+ }
14
+ export declare function inspectActiveStates(nuxtApp: object): StateInspectorEntry[];
15
+ export declare function classifyStateMember(value: unknown): string;
16
+ export declare function previewValue(value: unknown): unknown;
@@ -0,0 +1,149 @@
1
+ import { isReactive, isReadonly, isRef, isShallow, toRaw } from "vue";
2
+ import { getStateRegistry } from "./state-registry.js";
3
+ const MAX_DEPTH = 4;
4
+ const MAX_ITEMS = 40;
5
+ const MAX_NODES = 200;
6
+ const MAX_STRING = 500;
7
+ export function inspectActiveStates(nuxtApp) {
8
+ const entries = [];
9
+ for (const [key, entry] of getStateRegistry(nuxtApp).active) {
10
+ if (!entry.debug) continue;
11
+ const state = entry.debug.state;
12
+ const members = inspectMembers(state);
13
+ entries.push({
14
+ key,
15
+ name: entry.debug.name || `State ${key}`,
16
+ source: entry.debug.source,
17
+ origin: entry.debug.origin,
18
+ hydration: entry.debug.hydration,
19
+ members
20
+ });
21
+ }
22
+ return entries;
23
+ }
24
+ export function classifyStateMember(value) {
25
+ if (typeof value === "function") return "function";
26
+ if (isRef(value)) {
27
+ if (isReadonly(value)) return "readonly ref";
28
+ return isShallow(value) ? "shallowRef" : "ref";
29
+ }
30
+ if (isReactive(value)) {
31
+ if (isReadonly(value)) return "readonly";
32
+ return isShallow(value) ? "shallowReactive" : "reactive";
33
+ }
34
+ if (isReadonly(value)) return "readonly";
35
+ return "other";
36
+ }
37
+ export function previewValue(value) {
38
+ try {
39
+ const context = { seen: /* @__PURE__ */ new WeakMap(), nodes: 0, truncated: false };
40
+ const preview = visit(value, 0, context);
41
+ return context.truncated ? { preview, truncated: true } : preview;
42
+ } catch (error) {
43
+ return { unavailable: safeErrorMessage(error) };
44
+ }
45
+ }
46
+ function inspectMembers(state) {
47
+ if (!isObjectLike(state)) {
48
+ return [{ name: "value", kind: classifyStateMember(state), value: previewValue(state) }];
49
+ }
50
+ try {
51
+ return Object.keys(state).map((name) => {
52
+ try {
53
+ const value = state[name];
54
+ return {
55
+ name,
56
+ kind: classifyStateMember(value),
57
+ value: previewValue(isRef(value) ? value.value : value)
58
+ };
59
+ } catch (error) {
60
+ return { name, kind: "unavailable", value: { unavailable: safeErrorMessage(error) } };
61
+ }
62
+ });
63
+ } catch (error) {
64
+ return [
65
+ {
66
+ name: "value",
67
+ kind: "unavailable",
68
+ value: { unavailable: safeErrorMessage(error) }
69
+ }
70
+ ];
71
+ }
72
+ }
73
+ function visit(value, depth, context) {
74
+ if (typeof value === "string") {
75
+ if (value.length <= MAX_STRING) return value;
76
+ context.truncated = true;
77
+ return `${value.slice(0, MAX_STRING)}\u2026`;
78
+ }
79
+ if (value === null || ["number", "boolean", "undefined"].includes(typeof value)) return value;
80
+ if (typeof value === "bigint") return `${value}n`;
81
+ if (typeof value === "symbol") return value.toString();
82
+ if (typeof value === "function") return `\u0192 ${value.name || "anonymous"}()`;
83
+ if (typeof value !== "object") return String(value);
84
+ const raw = isReactive(value) || isReadonly(value) ? toRaw(value) : value;
85
+ const known = context.seen.get(raw);
86
+ if (known) return { reference: `#${known}` };
87
+ const id = ++context.nodes;
88
+ context.seen.set(raw, id);
89
+ if (context.nodes > MAX_NODES || depth >= MAX_DEPTH) {
90
+ context.truncated = true;
91
+ return { id: `#${id}`, truncated: true };
92
+ }
93
+ if (raw instanceof Date) {
94
+ return {
95
+ id: `#${id}`,
96
+ type: "Date",
97
+ value: Number.isNaN(raw.valueOf()) ? "Invalid Date" : raw.toISOString()
98
+ };
99
+ }
100
+ if (raw instanceof Map) {
101
+ const items = [...raw.entries()];
102
+ if (items.length > MAX_ITEMS) context.truncated = true;
103
+ return {
104
+ id: `#${id}`,
105
+ type: "Map",
106
+ entries: items.slice(0, MAX_ITEMS).map(([key, item]) => [visit(key, depth + 1, context), visit(item, depth + 1, context)]),
107
+ ...items.length > MAX_ITEMS ? { truncated: true, total: items.length } : {}
108
+ };
109
+ }
110
+ if (raw instanceof Set) {
111
+ const items = [...raw];
112
+ if (items.length > MAX_ITEMS) context.truncated = true;
113
+ return {
114
+ id: `#${id}`,
115
+ type: "Set",
116
+ values: items.slice(0, MAX_ITEMS).map((item) => visit(item, depth + 1, context)),
117
+ ...items.length > MAX_ITEMS ? { truncated: true, total: items.length } : {}
118
+ };
119
+ }
120
+ if (Array.isArray(raw)) {
121
+ if (raw.length > MAX_ITEMS) context.truncated = true;
122
+ return {
123
+ id: `#${id}`,
124
+ type: "Array",
125
+ values: raw.slice(0, MAX_ITEMS).map((item) => visit(item, depth + 1, context)),
126
+ ...raw.length > MAX_ITEMS ? { truncated: true, total: raw.length } : {}
127
+ };
128
+ }
129
+ if (Object.getPrototypeOf(raw) !== Object.prototype && Object.getPrototypeOf(raw) !== null) {
130
+ const constructor = raw.constructor;
131
+ return { id: `#${id}`, unsupported: constructor?.name || "Object" };
132
+ }
133
+ const properties = Object.entries(raw);
134
+ if (properties.length > MAX_ITEMS) context.truncated = true;
135
+ return {
136
+ id: `#${id}`,
137
+ properties: Object.fromEntries(
138
+ properties.slice(0, MAX_ITEMS).map(([key, item]) => [key, visit(item, depth + 1, context)])
139
+ ),
140
+ ...properties.length > MAX_ITEMS ? { truncated: true, total: properties.length } : {}
141
+ };
142
+ }
143
+ function isObjectLike(value) {
144
+ return typeof value === "object" && value !== null || typeof value === "function";
145
+ }
146
+ function safeErrorMessage(error) {
147
+ if (error instanceof Error) return error.message.slice(0, 200);
148
+ return "Value could not be inspected";
149
+ }
@@ -2,6 +2,14 @@ export type StateHydrationPayload = Record<string, unknown>;
2
2
  export interface HydratableStateEntry {
3
3
  snapshot: () => unknown;
4
4
  restore: (snapshot: unknown) => void;
5
+ dispose?: () => void;
6
+ debug?: {
7
+ state: unknown;
8
+ name?: string;
9
+ source?: string;
10
+ origin?: string;
11
+ hydration: 'Hydrated' | 'Client-only' | 'Server';
12
+ };
5
13
  }
6
14
  interface StateRegistry {
7
15
  active: Map<string, HydratableStateEntry>;
@@ -11,4 +19,5 @@ export declare function getStateRegistry(nuxtApp: object): StateRegistry;
11
19
  export declare function registerHydratableState(nuxtApp: object, key: string, entry: HydratableStateEntry): void;
12
20
  export declare function collectStateSnapshots(nuxtApp: object): StateHydrationPayload;
13
21
  export declare function receiveStateSnapshots(nuxtApp: object, snapshots: StateHydrationPayload | undefined): void;
22
+ export declare function clearPendingStateSnapshots(nuxtApp: object): void;
14
23
  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.3.0",
4
4
  "description": "Define shared Nuxt state using the same Composition API you already use in composables.",
5
5
  "keywords": [
6
6
  "composable",
@@ -44,6 +44,7 @@
44
44
  "access": "public"
45
45
  },
46
46
  "dependencies": {
47
+ "@nuxt/devtools-kit": "^3.4.2",
47
48
  "@nuxt/kit": "^4.5.2"
48
49
  },
49
50
  "devDependencies": {
@@ -77,8 +78,9 @@
77
78
  "fmt:check": "oxfmt --check",
78
79
  "test": "vitest run",
79
80
  "test:browser": "vitest run test/browser",
81
+ "test:stress": "vitest run --config vitest.stress.config.ts",
80
82
  "check": "pnpm run fmt:check && pnpm run lint",
81
83
  "test:watch": "vitest",
82
- "test:types": "nuxt-module-build build && vue-tsc --noEmit && pnpm --dir playground exec vue-tsc --noEmit"
84
+ "test:types": "nuxt-module-build prepare && nuxt-module-build build && vue-tsc --noEmit && pnpm --dir playground exec vue-tsc --noEmit"
83
85
  }
84
86
  }