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 +78 -142
- package/dist/module.json +1 -1
- package/dist/module.mjs +159 -3
- package/dist/runtime/app/composables/defineState.js +14 -2
- package/dist/runtime/app/devtools-metadata.d.ts +4 -0
- package/dist/runtime/app/plugins/devtools.client.d.ts +2 -0
- package/dist/runtime/app/plugins/devtools.client.js +17 -0
- package/dist/runtime/app/state-inspector.d.ts +16 -0
- package/dist/runtime/app/state-inspector.js +149 -0
- package/dist/runtime/app/state-registry.d.ts +7 -0
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -1,32 +1,14 @@
|
|
|
1
1
|
# nuxt-state
|
|
2
2
|
|
|
3
|
-
> Define shared Nuxt state
|
|
3
|
+
> Define shared Nuxt state with the Vue Composition API.
|
|
4
4
|
|
|
5
|
-
`nuxt-state` is
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
|
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
|
|
67
|
-
are auto-imported. `defineState` is also auto-imported and
|
|
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
|
-
|
|
81
|
-
|
|
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
|
-
|
|
84
|
-
refs remain computed refs, reactive objects remain reactive, and functions are unchanged.
|
|
60
|
+
## Nuxt Layers
|
|
85
61
|
|
|
86
|
-
|
|
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
|
-
|
|
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
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
|
99
|
+
return { count, user, double }
|
|
125
100
|
})
|
|
126
101
|
```
|
|
127
102
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
by the factory rather than serialized.
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
- `ref
|
|
155
|
-
-
|
|
156
|
-
-
|
|
157
|
-
|
|
158
|
-
-
|
|
159
|
-
|
|
160
|
-
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
-
|
|
172
|
-
|
|
173
|
-
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
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
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
|
-
|
|
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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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
|
-
|
|
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,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.
|
|
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
|
}
|