nuxt-state 0.2.0 → 1.0.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
@@ -1,32 +1,14 @@
1
1
  # nuxt-state
2
2
 
3
- > Define shared Nuxt state using the same Composition API you already use in composables.
3
+ > Define shared Nuxt state with the Vue Composition API.
4
4
 
5
- `nuxt-state` is an experimental Nuxt 4 module that explores one small primitive:
5
+ `nuxt-state` is a focused Nuxt 4 module. A composable created with `defineState` runs lazily
6
+ once per Nuxt application and returns the same result to every caller in that app. Its stable
7
+ 1.x public API is `defineState(factory)`.
6
8
 
7
- ```ts
8
- defineState(() => {
9
- // standard Vue Composition API
10
- return {/* public state */}
11
- })
12
- ```
13
-
14
- A regular composable runs its factory for every invocation. A composable created by
15
- `defineState` runs its factory lazily, once for the current Nuxt application instance,
16
- and returns that exact result to every caller in that app.
17
-
18
- This is a working open-source prototype for discussion and possible future contribution to
19
- Nuxt. The API is intentionally narrow and the project is not yet presented as production-ready.
20
-
21
- ## Why
22
-
23
- Nuxt's `useState()` is excellent for simple SSR-aware values. Pinia is a strong choice
24
- when an application wants a dedicated state-management library and its ecosystem.
25
- This project does not replace either one. It explores a lightweight native abstraction
26
- between a raw `useState()` value and a full state-management library.
27
-
28
- There are no stores, actions, getters, mutations, IDs, configuration objects, or special
29
- wrappers. The factory and its return value use normal Vue semantics.
9
+ It sits between a simple `useState()` value and a full state-management library such as
10
+ Pinia. There are no stores, IDs, actions, getters, or special wrappers—only standard Vue
11
+ primitives.
30
12
 
31
13
  ## Install
32
14
 
@@ -43,7 +25,7 @@ export default defineNuxtConfig({
43
25
 
44
26
  ## Usage
45
27
 
46
- Create a state in Nuxt 4's application source directory:
28
+ Create states in `app/states/`:
47
29
 
48
30
  ```ts
49
31
  // app/states/counter.ts
@@ -55,154 +37,110 @@ export const useCounter = defineState(() => {
55
37
  count.value++
56
38
  }
57
39
 
58
- return {
59
- count,
60
- double,
61
- increment,
62
- }
40
+ return { count, double, increment }
63
41
  })
64
42
  ```
65
43
 
66
- Exports from `app/states/`, including nested directories and multiple exports per file,
67
- are auto-imported. `defineState` is also auto-imported and can be used elsewhere, such
68
- as in `app/composables/`.
44
+ Exports from this directory, including nested directories and multiple exports per file,
45
+ are auto-imported. `defineState` is also auto-imported and may be used elsewhere.
69
46
 
70
47
  ```vue
71
48
  <script setup lang="ts">
72
49
  const { count, double, increment } = useCounter()
73
50
 
74
- count.value++
75
51
  increment()
76
52
  console.log(double.value)
77
53
  </script>
78
54
  ```
79
55
 
80
- Calling `useCounter()` in ten components returns the same object for that Nuxt app.
81
- Different server requests and different client apps receive different objects.
56
+ All callers within one Nuxt app receive the exact factory result. Refs, reactive objects,
57
+ computed refs, and functions retain their normal Vue behavior. Server requests and separate
58
+ client apps never share instances.
82
59
 
83
- The returned object is not cloned, wrapped, or transformed. Refs remain refs, computed
84
- refs remain computed refs, reactive objects remain reactive, and functions are unchanged.
60
+ ## Nuxt Layers
85
61
 
86
- ## Semantics
62
+ States in the resolved `app/states/` directory of local, extended, or package-provided
63
+ [Nuxt Layers](https://nuxt.com/docs/4.x/guide/going-further/layers) are auto-imported too.
64
+ Normal Nuxt priority applies: project exports override layer exports, and higher-priority
65
+ layers override lower-priority ones.
87
66
 
88
- - The factory is synchronous and takes no arguments.
89
- - The generated composable takes no arguments.
90
- - The factory is lazy and runs at first use.
91
- - It runs once per Nuxt app instance.
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.
95
- - A module-local `WeakMap` keys instances by `NuxtApp`, providing request isolation while
96
- allowing old application instances to be garbage-collected.
97
- - Separate `defineState()` calls have separate closure-owned caches, including calls in
98
- the same file.
99
- - Mutable state used during SSR is restored into the client-created refs and reactive proxies
100
- before Vue hydrates the component tree.
67
+ ## DevTools
101
68
 
102
- Async factories are rejected by TypeScript and guarded at runtime for JavaScript users.
103
- Expose an async function from synchronous state or use Nuxt's data-fetching APIs instead.
69
+ With Nuxt DevTools enabled, a development-only **Nuxt State** tab provides read-only views
70
+ of active states and discovered state exports. It shows hydration status, safe bounded value
71
+ previews, source paths, and layer origins without executing lazy factories.
104
72
 
105
- ## SSR hydration
73
+ The inspector cannot mutate state or invoke functions. It polls only while visible, adds no
74
+ production runtime code, and labels active instances by their internal hydration key because
75
+ Nuxt does not expose a stable mapping from that key to auto-import metadata.
106
76
 
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)`.
77
+ ## Behavior
110
78
 
111
- On the server, the module captures the final values after rendering in one namespaced Nuxt
112
- payload entry. On the client, it runs the factory normally and patches those values into the
113
- new refs and reactive proxies before Vue hydration. The returned object is never replaced, so
114
- computed refs, functions, watchers, and closures created by the client factory remain wired to
115
- the hydrated state.
79
+ - The factory and generated composable are synchronous and take no arguments.
80
+ - The factory runs lazily, once per Nuxt app, inside an app-lived Vue effect scope.
81
+ - Instances are stored in a `WeakMap` keyed by `NuxtApp` for SSR request isolation and garbage
82
+ collection.
83
+ - Separate `defineState()` calls always have separate caches.
84
+ - HMR recreates the state instead of preserving it.
85
+ - Async work should be exposed as a function or use Nuxt data composables such as `useFetch`.
86
+
87
+ ## SSR hydration
88
+
89
+ Mutable top-level refs and reactive objects returned by the factory are snapshotted after SSR
90
+ and restored into client-created values before Vue hydration. The factory result is never
91
+ replaced, so its computed refs, watchers, functions, and closures remain connected.
116
92
 
117
93
  ```ts
118
94
  export const useAccount = defineState(() => {
119
95
  const count = ref(0)
120
96
  const user = reactive({ name: 'Guest', roles: [] as string[] })
121
97
  const double = computed(() => count.value * 2)
122
- const increment = () => count.value++
123
98
 
124
- return { count, user, double, increment }
99
+ return { count, user, double }
125
100
  })
126
101
  ```
127
102
 
128
- Nested serializable values inside supported refs and reactive objects are handled by Nuxt's
129
- payload serializer. Functions, readonly computed refs, and plain runtime objects are recreated
130
- by the factory rather than serialized. Concurrent SSR requests retain separate Nuxt-app
131
- registries and cannot share user state.
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.
103
+ Nuxt's payload serializer handles nested serializable values, including supported `Date`,
104
+ `Map`, `Set`, shared-reference, and cyclic graphs. Functions and readonly computed refs are
105
+ recreated by the client factory rather than serialized.
139
106
 
140
- `useFetch()` can remain inside a synchronous state factory. Its request caching and payload
141
- hydration still belong to Nuxt; `nuxt-state` neither replaces nor triggers a second fetch
142
- mechanism. If multiple sibling SSR components must all render completed data, await the returned
143
- Nuxt `AsyncData` promise in a parent/page as you would with normal Nuxt data fetching.
107
+ Reactive state that must survive SSR hydration currently needs to be exposed from the
108
+ `defineState` factory. Closure-private refs cannot be discovered; mutating one during SSR may
109
+ therefore cause a hydration mismatch.
144
110
 
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.
111
+ `useFetch()` and `useAsyncData()` retain Nuxt's request caching and payload ownership. When
112
+ their returned data ref is exposed, Nuxt's graph serializer keeps the shared payload object
113
+ instead of duplicating the response body. The state snapshot adds only metadata overhead.
149
114
 
150
115
  ## Compatibility
151
116
 
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
-
184
- ## Current limitations
185
-
186
- - Nuxt 4 and Vue 3 only.
187
- - Synchronous factories only.
188
- - No persistence or browser-storage integration.
189
- - Hydration is guaranteed for standard and shallow refs/reactives. `customRef()` and writable
190
- computed hydration are not supported.
191
- - Hydrated values must be serializable by Nuxt's payload system; DOM nodes, sockets, functions
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.
195
- - No Nuxt Layers support yet.
196
- - State resets when its module is hot-reloaded; HMR preservation is not implemented.
197
- - Context-sensitive composables must still be called while normal Nuxt context is available.
198
- - There is no reset API, keyed/multi-instance state, DevTools integration, or central
199
- user-facing registry.
200
- - The keyed transform is source-sensitive. The supported path is the module's auto-imported
201
- `defineState`; a barrel re-export or unrelated manual wrapper is not guaranteed to receive an
202
- internal hydration key.
203
-
204
- See [the architecture notes](./docs/architecture.md) and [roadmap](./docs/roadmap.md) for
205
- implementation constraints and deferred work.
117
+ Supported:
118
+
119
+ - `ref`, `reactive`, `shallowRef`, and `shallowReactive`;
120
+ - readonly computed values and functions recreated by the client factory;
121
+ - readonly views whose mutable source is also returned;
122
+ - Nuxt-serializable nested values, shared references, and cycles;
123
+ - `useFetch`, `useAsyncData`, `callOnce`, `useCookie`, `useRuntimeConfig`, `useRoute`, and
124
+ `useRouter` in valid Nuxt contexts;
125
+ - first use from plugins, middleware, layouts, pages, and components;
126
+ - project and Nuxt Layer states.
127
+
128
+ Limitations:
129
+
130
+ - Nuxt 4 and Vue 3 only; synchronous factories only.
131
+ - Hydrated state must be an enumerable object containing supported mutable members.
132
+ - Closure-private mutable state, `customRef`, and writable computed hydration are unsupported.
133
+ Restoring a writable computed may invoke its setter, so return its source refs instead.
134
+ - Payload values must be serializable by Nuxt; DOM nodes, sockets, symbols, functions inside
135
+ refs, and arbitrary class/native resources are unsupported.
136
+ - No persistence, reset API, keyed instances, or HMR state preservation.
137
+ - Context-sensitive Nuxt composables still require a valid Nuxt context.
138
+ - The supported transform path is the module's auto-imported `defineState`; barrel re-exports
139
+ and unrelated wrappers are not guaranteed to receive a hydration key.
140
+ - DevTools is development-only and read-only.
141
+
142
+ See [architecture](./docs/architecture.md) for implementation details and the
143
+ [roadmap](./docs/roadmap.md) for deferred work.
206
144
 
207
145
  ## Development
208
146
 
@@ -220,9 +158,7 @@ pnpm prepack
220
158
  pnpm dev:build
221
159
  ```
222
160
 
223
- The browser suite requires Chromium, installed with
224
- `pnpm exec playwright-core install chromium`. The playground contains two counter components,
225
- a reactive object example, a nested state, and two independent states exported from one file.
161
+ Install Chromium for browser tests with `pnpm exec playwright-core install chromium`.
226
162
 
227
163
  ## License
228
164
 
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.2.0",
7
+ "version": "1.0.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 };
@@ -30,11 +30,23 @@ export function defineState(factory, internalKey) {
30
30
  const app = nuxtApp;
31
31
  app.vueApp?.onUnmount?.(() => scope.stop());
32
32
  if (internalKey) {
33
- registerHydratableState(nuxtApp, internalKey, {
33
+ const entry = {
34
34
  snapshot: () => snapshotState(instance),
35
35
  restore: (snapshot) => restoreState(instance, snapshot),
36
36
  dispose: () => scope.stop()
37
- });
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);
38
50
  }
39
51
  return instance;
40
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
+ });
@@ -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
+ }
@@ -3,6 +3,13 @@ export interface HydratableStateEntry {
3
3
  snapshot: () => unknown;
4
4
  restore: (snapshot: unknown) => void;
5
5
  dispose?: () => void;
6
+ debug?: {
7
+ state: unknown;
8
+ name?: string;
9
+ source?: string;
10
+ origin?: string;
11
+ hydration: 'Hydrated' | 'Client-only' | 'Server';
12
+ };
6
13
  }
7
14
  interface StateRegistry {
8
15
  active: Map<string, HydratableStateEntry>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nuxt-state",
3
- "version": "0.2.0",
3
+ "version": "1.0.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": {
@@ -52,12 +53,14 @@
52
53
  "@nuxt/module-builder": "^1.0.3",
53
54
  "@nuxt/schema": "^4.5.2",
54
55
  "@nuxt/test-utils": "^4.1.0",
56
+ "@pinia/nuxt": "1.0.2",
55
57
  "@types/node": "latest",
56
58
  "changelogen": "^0.6.2",
57
59
  "happy-dom": "^20.8.3",
58
60
  "nuxt": "^4.5.2",
59
61
  "oxfmt": "^0.65.0",
60
62
  "oxlint": "^1.80.0",
63
+ "pinia": "4.0.3",
61
64
  "playwright-core": "^1.62.1",
62
65
  "typescript": "^6.0.3",
63
66
  "vitest": "^4.1.11",
@@ -68,6 +71,7 @@
68
71
  "node": ">=22"
69
72
  },
70
73
  "scripts": {
74
+ "bench:bundle-size": "pnpm run prepack && node benchmarks/bundle-size/run.mjs",
71
75
  "dev": "pnpm dev:prepare && nuxt dev playground",
72
76
  "dev:build": "nuxt build playground",
73
77
  "dev:prepare": "nuxt-module-build build --stub && nuxt-module-build prepare && nuxt prepare playground",
@@ -80,6 +84,6 @@
80
84
  "test:stress": "vitest run --config vitest.stress.config.ts",
81
85
  "check": "pnpm run fmt:check && pnpm run lint",
82
86
  "test:watch": "vitest",
83
- "test:types": "nuxt-module-build prepare && nuxt-module-build build && vue-tsc --noEmit && pnpm --dir playground exec vue-tsc --noEmit"
87
+ "test:types": "nuxt-module-build prepare && nuxt-module-build build && vue-tsc --noEmit && nuxt prepare playground && pnpm --dir playground exec vue-tsc --noEmit"
84
88
  }
85
89
  }