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 +117 -11
- package/dist/module.json +1 -1
- package/dist/module.mjs +159 -3
- package/dist/runtime/app/composables/defineState.js +28 -4
- 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/plugins/hydration.js +4 -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 +9 -0
- package/dist/runtime/app/state-registry.js +4 -0
- package/dist/runtime/app/state-snapshot.js +19 -15
- package/package.json +4 -2
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
|
|
106
|
-
|
|
107
|
-
|
|
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
|
|
142
|
-
|
|
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
|
-
-
|
|
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
|
-
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
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 };
|
|
@@ -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
|
|
15
|
+
const scope = effectScope(true);
|
|
16
|
+
let instance;
|
|
17
|
+
try {
|
|
18
|
+
instance = scope.run(factory);
|
|
19
|
+
} catch (error) {
|
|
20
|
+
scope.stop();
|
|
21
|
+
throw error;
|
|
22
|
+
}
|
|
15
23
|
if (isPromiseLike(instance)) {
|
|
24
|
+
scope.stop();
|
|
16
25
|
throw new TypeError(
|
|
17
26
|
"[nuxt-state] State factories must be synchronous. Expose an async function from the state or use Nuxt data-fetching composables instead."
|
|
18
27
|
);
|
|
19
28
|
}
|
|
20
29
|
instances.set(nuxtApp, instance);
|
|
30
|
+
const app = nuxtApp;
|
|
31
|
+
app.vueApp?.onUnmount?.(() => scope.stop());
|
|
21
32
|
if (internalKey) {
|
|
22
|
-
|
|
33
|
+
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,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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
}
|