nuxt-state 0.0.2 → 0.1.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
@@ -15,8 +15,8 @@ A regular composable runs its factory for every invocation. A composable created
15
15
  `defineState` runs its factory lazily, once for the current Nuxt application instance,
16
16
  and returns that exact result to every caller in that app.
17
17
 
18
- This is a working prototype for discussion and possible future contribution to Nuxt.
19
- It is not yet presented as production-ready.
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
20
 
21
21
  ## Why
22
22
 
@@ -41,9 +41,6 @@ export default defineNuxtConfig({
41
41
  })
42
42
  ```
43
43
 
44
- The package has not been published yet; during development, use this repository as a
45
- workspace dependency.
46
-
47
44
  ## Usage
48
45
 
49
46
  Create a state in Nuxt 4's application source directory:
@@ -97,35 +94,67 @@ refs remain computed refs, reactive objects remain reactive, and functions are u
97
94
  allowing old application instances to be garbage-collected.
98
95
  - Separate `defineState()` calls have separate closure-owned caches, including calls in
99
96
  the same file.
97
+ - Mutable state used during SSR is restored into the client-created refs and reactive proxies
98
+ before Vue hydrates the component tree.
100
99
 
101
100
  Async factories are rejected by TypeScript and guarded at runtime for JavaScript users.
102
101
  Expose an async function from synchronous state or use Nuxt's data-fetching APIs instead.
103
102
 
104
- ## SSR scope
103
+ ## SSR hydration
104
+
105
+ v0.1.0 transparently hydrates mutable top-level members returned by the factory when they are
106
+ created with `ref()` or `reactive()`. Nuxt injects an internal call-site key at build time; the
107
+ developer-facing call remains exactly `defineState(factory)`.
108
+
109
+ On the server, the module captures the final values after rendering in one namespaced Nuxt
110
+ payload entry. On the client, it runs the factory normally and patches those values into the
111
+ new refs and reactive proxies before Vue hydration. The returned object is never replaced, so
112
+ computed refs, functions, watchers, and closures created by the client factory remain wired to
113
+ the hydrated state.
114
+
115
+ ```ts
116
+ export const useAccount = defineState(() => {
117
+ const count = ref(0)
118
+ const user = reactive({ name: 'Guest', roles: [] as string[] })
119
+ const double = computed(() => count.value * 2)
120
+ const increment = () => count.value++
121
+
122
+ return { count, user, double, increment }
123
+ })
124
+ ```
105
125
 
106
- v0 guarantees isolation between SSR requests: module-level state does not become a
107
- process-wide user-state singleton. This is intentionally different from hydration.
126
+ Nested serializable values inside supported refs and reactive objects are handled by Nuxt's
127
+ payload serializer. Functions, readonly computed refs, and plain runtime objects are recreated
128
+ by the factory rather than serialized. Concurrent SSR requests retain separate Nuxt-app
129
+ registries and cannot share user state.
108
130
 
109
- v0 does **not** serialize or hydrate arbitrary factory results. A result may contain
110
- functions, computed refs, class instances, and other runtime-only values. If a state is
111
- mutated during SSR, the client may recreate the factory's initial state during hydration.
112
- Do not rely on server mutations transferring to the client yet.
131
+ `useFetch()` can remain inside a synchronous state factory. Its request caching and payload
132
+ hydration still belong to Nuxt; `nuxt-state` neither replaces nor triggers a second fetch
133
+ mechanism. If multiple sibling SSR components must all render completed data, await the returned
134
+ Nuxt `AsyncData` promise in a parent/page as you would with normal Nuxt data fetching.
113
135
 
114
136
  ## Current limitations
115
137
 
116
138
  - Nuxt 4 and Vue 3 only.
117
139
  - Synchronous factories only.
118
140
  - No persistence or browser-storage integration.
119
- - No arbitrary-state payload serialization or hydration yet.
141
+ - Hydration is guaranteed for standard `ref()` and `reactive()` members only. Advanced
142
+ primitives such as shallow/custom refs and writable-computed edge cases are not v0.1.0
143
+ guarantees.
144
+ - Hydrated values must be serializable by Nuxt's payload system; DOM nodes, sockets, functions
145
+ inside refs, symbols, and arbitrary native/class resources are unsupported.
120
146
  - No Nuxt Layers support yet.
121
147
  - State resets when its module is hot-reloaded; HMR preservation is not implemented.
122
148
  - Compatibility with every context-sensitive Nuxt composable inside a factory is not
123
149
  guaranteed yet.
124
150
  - There is no reset API, keyed/multi-instance state, DevTools integration, or central
125
- registry.
151
+ user-facing registry.
152
+ - The keyed transform is source-sensitive. The supported path is the module's auto-imported
153
+ `defineState`; a barrel re-export or unrelated manual wrapper is not guaranteed to receive an
154
+ internal hydration key.
126
155
 
127
- See [ROADMAP.md](./ROADMAP.md) for the technical questions behind future hydration and
128
- context compatibility.
156
+ See [the architecture notes](./docs/architecture.md) and [roadmap](./docs/roadmap.md) for
157
+ implementation constraints and deferred work.
129
158
 
130
159
  ## Development
131
160
 
@@ -134,6 +163,7 @@ Requires Node.js 22+ and pnpm.
134
163
  ```bash
135
164
  pnpm install
136
165
  pnpm dev
166
+ pnpm fmt:check
137
167
  pnpm lint
138
168
  pnpm test:types
139
169
  pnpm test
@@ -141,8 +171,9 @@ pnpm prepack
141
171
  pnpm dev:build
142
172
  ```
143
173
 
144
- The playground contains two counter components, a reactive object example, a nested
145
- state, and two independent states exported from one file.
174
+ The browser suite requires Chromium, installed with
175
+ `pnpm exec playwright-core install chromium`. The playground contains two counter components,
176
+ a reactive object example, a nested state, and two independent states exported from one file.
146
177
 
147
178
  ## License
148
179
 
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.0.2",
7
+ "version": "0.1.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,5 @@
1
1
  import { resolve } from 'node:path';
2
- import { defineNuxtModule, createResolver, addImports, addImportsDir } from '@nuxt/kit';
2
+ import { defineNuxtModule, createResolver, addImports, addPlugin, addImportsDir } from '@nuxt/kit';
3
3
 
4
4
  const module$1 = defineNuxtModule({
5
5
  meta: {
@@ -11,10 +11,17 @@ const module$1 = defineNuxtModule({
11
11
  defaults: {},
12
12
  setup(_options, nuxt) {
13
13
  const resolver = createResolver(import.meta.url);
14
+ const defineStateSource = resolver.resolve("./runtime/app/composables/defineState");
14
15
  addImports({
15
16
  name: "defineState",
16
- from: resolver.resolve("./runtime/app/composables/defineState")
17
+ from: defineStateSource
17
18
  });
19
+ nuxt.options.optimization.keyedComposables.push({
20
+ name: "defineState",
21
+ source: defineStateSource,
22
+ argumentLength: 2
23
+ });
24
+ addPlugin(resolver.resolve("./runtime/app/plugins/hydration"));
18
25
  addImportsDir(resolve(nuxt.options.srcDir, "states/**"));
19
26
  }
20
27
  });
@@ -1,8 +1,10 @@
1
1
  import { useNuxtApp } from "#app";
2
+ import { registerHydratableState } from "../state-registry.js";
3
+ import { restoreState, snapshotState } from "../state-snapshot.js";
2
4
  function isPromiseLike(value) {
3
5
  return (typeof value === "object" && value !== null || typeof value === "function") && "then" in value && typeof value.then === "function";
4
6
  }
5
- export function defineState(factory) {
7
+ export function defineState(factory, internalKey) {
6
8
  const instances = /* @__PURE__ */ new WeakMap();
7
9
  return function useDefinedState() {
8
10
  const nuxtApp = useNuxtApp();
@@ -16,6 +18,12 @@ export function defineState(factory) {
16
18
  );
17
19
  }
18
20
  instances.set(nuxtApp, instance);
21
+ if (internalKey) {
22
+ registerHydratableState(nuxtApp, internalKey, {
23
+ snapshot: () => snapshotState(instance),
24
+ restore: (snapshot) => restoreState(instance, snapshot)
25
+ });
26
+ }
19
27
  return instance;
20
28
  };
21
29
  }
@@ -0,0 +1,3 @@
1
+ export declare const STATE_PAYLOAD_KEY: "__nuxt_state__";
2
+ declare const _default: import("nuxt/app").Plugin<Record<string, unknown>> & import("nuxt/app").ObjectPlugin<Record<string, unknown>>;
3
+ export default _default;
@@ -0,0 +1,13 @@
1
+ import { defineNuxtPlugin, useHydration } from "#app";
2
+ import {
3
+ collectStateSnapshots,
4
+ receiveStateSnapshots
5
+ } from "../state-registry.js";
6
+ export const STATE_PAYLOAD_KEY = "__nuxt_state__";
7
+ export default defineNuxtPlugin((nuxtApp) => {
8
+ useHydration(
9
+ STATE_PAYLOAD_KEY,
10
+ () => collectStateSnapshots(nuxtApp),
11
+ (snapshots) => receiveStateSnapshots(nuxtApp, snapshots)
12
+ );
13
+ });
@@ -0,0 +1,14 @@
1
+ export type StateHydrationPayload = Record<string, unknown>;
2
+ export interface HydratableStateEntry {
3
+ snapshot: () => unknown;
4
+ restore: (snapshot: unknown) => void;
5
+ }
6
+ interface StateRegistry {
7
+ active: Map<string, HydratableStateEntry>;
8
+ hydration: Map<string, unknown>;
9
+ }
10
+ export declare function getStateRegistry(nuxtApp: object): StateRegistry;
11
+ export declare function registerHydratableState(nuxtApp: object, key: string, entry: HydratableStateEntry): void;
12
+ export declare function collectStateSnapshots(nuxtApp: object): StateHydrationPayload;
13
+ export declare function receiveStateSnapshots(nuxtApp: object, snapshots: StateHydrationPayload | undefined): void;
14
+ export {};
@@ -0,0 +1,38 @@
1
+ const registries = /* @__PURE__ */ new WeakMap();
2
+ export function getStateRegistry(nuxtApp) {
3
+ let registry = registries.get(nuxtApp);
4
+ if (!registry) {
5
+ registry = {
6
+ active: /* @__PURE__ */ new Map(),
7
+ hydration: /* @__PURE__ */ new Map()
8
+ };
9
+ registries.set(nuxtApp, registry);
10
+ }
11
+ return registry;
12
+ }
13
+ export function registerHydratableState(nuxtApp, key, entry) {
14
+ const registry = getStateRegistry(nuxtApp);
15
+ registry.active.set(key, entry);
16
+ if (registry.hydration.has(key)) {
17
+ const snapshot = registry.hydration.get(key);
18
+ registry.hydration.delete(key);
19
+ entry.restore(snapshot);
20
+ }
21
+ }
22
+ export function collectStateSnapshots(nuxtApp) {
23
+ const snapshots = {};
24
+ for (const [key, entry] of getStateRegistry(nuxtApp).active) {
25
+ snapshots[key] = entry.snapshot();
26
+ }
27
+ return snapshots;
28
+ }
29
+ export function receiveStateSnapshots(nuxtApp, snapshots) {
30
+ const registry = getStateRegistry(nuxtApp);
31
+ registry.hydration = new Map(Object.entries(snapshots ?? {}));
32
+ for (const [key, entry] of registry.active) {
33
+ if (!registry.hydration.has(key)) continue;
34
+ const snapshot = registry.hydration.get(key);
35
+ registry.hydration.delete(key);
36
+ entry.restore(snapshot);
37
+ }
38
+ }
@@ -0,0 +1,13 @@
1
+ interface RefSnapshot {
2
+ type: 'ref';
3
+ value: unknown;
4
+ }
5
+ interface ReactiveSnapshot {
6
+ type: 'reactive';
7
+ value: unknown;
8
+ }
9
+ type StateSnapshotEntry = RefSnapshot | ReactiveSnapshot;
10
+ export type StateSnapshot = Record<string, StateSnapshotEntry>;
11
+ export declare function snapshotState(state: unknown): StateSnapshot;
12
+ export declare function restoreState(state: unknown, snapshot: unknown): void;
13
+ export {};
@@ -0,0 +1,70 @@
1
+ import { isReactive, isReadonly, isRef, toRaw } from "vue";
2
+ function isObjectLike(value) {
3
+ return typeof value === "object" && value !== null || typeof value === "function";
4
+ }
5
+ function unwrapReactive(value) {
6
+ return isReactive(value) ? toRaw(value) : value;
7
+ }
8
+ export function snapshotState(state) {
9
+ const snapshot = {};
10
+ if (!isObjectLike(state)) return snapshot;
11
+ for (const [name, value] of Object.entries(state)) {
12
+ if (isRef(value) && !isReadonly(value)) {
13
+ snapshot[name] = {
14
+ type: "ref",
15
+ value: unwrapReactive(value.value)
16
+ };
17
+ } else if (isReactive(value) && !isReadonly(value)) {
18
+ snapshot[name] = {
19
+ type: "reactive",
20
+ value: toRaw(value)
21
+ };
22
+ }
23
+ }
24
+ return snapshot;
25
+ }
26
+ function isPlainRecord(value) {
27
+ return Object.prototype.toString.call(value) === "[object Object]";
28
+ }
29
+ function canPatch(target, source) {
30
+ return Array.isArray(target) && Array.isArray(source) || isPlainRecord(target) && isPlainRecord(source);
31
+ }
32
+ function patchValue(target, source) {
33
+ if (Array.isArray(target) && Array.isArray(source)) {
34
+ for (let index = 0; index < source.length; index++) {
35
+ if (canPatch(target[index], source[index])) {
36
+ patchValue(target[index], source[index]);
37
+ } else {
38
+ target[index] = source[index];
39
+ }
40
+ }
41
+ target.length = source.length;
42
+ return;
43
+ }
44
+ if (!isPlainRecord(target) || !isPlainRecord(source)) return;
45
+ for (const key of Object.keys(target)) {
46
+ if (!(key in source)) delete target[key];
47
+ }
48
+ for (const [key, value] of Object.entries(source)) {
49
+ if (canPatch(target[key], value)) {
50
+ patchValue(target[key], value);
51
+ } else {
52
+ target[key] = value;
53
+ }
54
+ }
55
+ }
56
+ function isStateSnapshotEntry(value) {
57
+ return isPlainRecord(value) && (value.type === "ref" || value.type === "reactive") && Object.hasOwn(value, "value");
58
+ }
59
+ export function restoreState(state, snapshot) {
60
+ if (!isObjectLike(state) || !isPlainRecord(snapshot)) return;
61
+ for (const [name, entry] of Object.entries(snapshot)) {
62
+ if (!isStateSnapshotEntry(entry)) continue;
63
+ const target = state[name];
64
+ if (entry.type === "ref" && isRef(target) && !isReadonly(target)) {
65
+ target.value = entry.value;
66
+ } else if (entry.type === "reactive" && isReactive(target) && !isReadonly(target)) {
67
+ patchValue(target, entry.value);
68
+ }
69
+ }
70
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nuxt-state",
3
- "version": "0.0.2",
3
+ "version": "0.1.0",
4
4
  "description": "Define shared Nuxt state using the same Composition API you already use in composables.",
5
5
  "keywords": [
6
6
  "composable",
@@ -58,6 +58,7 @@
58
58
  "nuxt": "^4.5.2",
59
59
  "oxfmt": "^0.65.0",
60
60
  "oxlint": "^1.80.0",
61
+ "playwright-core": "^1.62.1",
61
62
  "typescript": "^6.0.3",
62
63
  "vitest": "^4.1.11",
63
64
  "vue": "^3.5.31",
@@ -75,8 +76,9 @@
75
76
  "fmt": "oxfmt",
76
77
  "fmt:check": "oxfmt --check",
77
78
  "test": "vitest run",
78
- "check": "pnpm run fmt && pnpm run lint",
79
+ "test:browser": "vitest run test/browser",
80
+ "check": "pnpm run fmt:check && pnpm run lint",
79
81
  "test:watch": "vitest",
80
- "test:types": "vue-tsc --noEmit && pnpm --dir playground exec vue-tsc --noEmit"
82
+ "test:types": "nuxt-module-build build && vue-tsc --noEmit && pnpm --dir playground exec vue-tsc --noEmit"
81
83
  }
82
84
  }