@lark.js/mvc 0.0.14 → 0.0.16

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
@@ -5,10 +5,10 @@ A TypeScript MVC framework designed for back-office single-page applications and
5
5
  `@lark.js/mvc` explicitly separates Model, View, and Controller layers: state management aligns with the zustand design (`create` / `getState` / `setState` / `subscribe`), routing supports both history and hash modes, templates compile to functions and render via real DOM diff, and micro-frontends are natively supported through the built-in CrossSite bridge and first-class Webpack Module Federation integration. The framework has zero runtime third-party dependencies; the template runtime helper module weighs approximately 1 KB (`dist/runtime.js` measured at 964 bytes).
6
6
 
7
7
  - Package: `@lark.js/mvc`
8
- - Version: see `package.json` (currently 0.0.5)
9
- - Entry points: `./` main entry, `./vite` build plugin, `./webpack` loader, `./runtime` template runtime
8
+ - Version: see `package.json` (currently 0.0.15)
9
+ - Entry points: `./` main entry, `./vite` Vite plugin, `./webpack` Webpack loader, `./rspack` Rspack loader, `./runtime` template runtime, `./compiler` compile-time API
10
10
  - Build: tsup, producing ESM + CJS + `.d.ts` in `dist/`
11
- - Tests: vitest, 16 test files covering core modules
11
+ - Tests: vitest, 24 test files, 500+ test cases covering core modules and HMR
12
12
 
13
13
  ## Table of Contents
14
14
 
@@ -22,6 +22,7 @@ A TypeScript MVC framework designed for back-office single-page applications and
22
22
  - Template Syntax
23
23
  - Frame and the View Tree
24
24
  - Module Federation Micro-Frontend
25
+ - Hot Module Replacement (HMR)
25
26
  - Debugging and Devtool Bridge
26
27
  - Public API Reference
27
28
  - Common Pitfalls
@@ -33,7 +34,7 @@ A TypeScript MVC framework designed for back-office single-page applications and
33
34
 
34
35
  Lark's trade-offs center around one category of requirements: back-office business systems with deep route hierarchies, heavy forms and API calls, and the need to compose several independent applications into a single shell. The framework makes explicit choices along the following dimensions.
35
36
 
36
- First, explicit layering. The Model layer provides `State` / `Store` (zustand-style) / `Service`, the View layer provides `View` / `Updater`, and the Controller layer provides `Router` (history/hash dual mode) / `Frame`. These communicate through explicit interfaces and events, allowing new team members to locate code by layer.
37
+ First, explicit layering. The Model layer provides `State` / `createStore()` (zustand-style) / `createService()`, the View layer provides `defineView()` / `Updater`, and the Controller layer provides `Router` (history/hash dual mode) / `Frame`. These communicate through explicit interfaces and events, allowing new team members to locate code by layer.
37
38
 
38
39
  Second, native micro-frontend support. The `CrossSite` bridge view + `FrameworkConfig.require` + Module Federation form a complete pipeline. Write `v-lark="remote-app/views/home"` in a template and the remote view loads and mounts automatically, eliminating the need for secondary containers like single-spa or qiankun.
39
40
 
@@ -41,7 +42,7 @@ Third, zero runtime dependencies. `@babel/parser` / `@babel/types` are used only
41
42
 
42
43
  Fourth, real DOM diff. Templates compile to functions that produce HTML strings, which are parsed into temporary DOM via `document.implementation.createHTMLDocument` and then diffed against the live DOM using keyed comparison. The advantage is that context-sensitive tags like `<table>` / `<select>` / `<svg>` are handled by the native parser. The trade-off is that large templates incur parse overhead, and SSR is not supported.
43
44
 
44
- Fifth, debug-friendly. `installFrameDevtoolBridge` exposes the Frame tree to Devtool via `postMessage`. A set of `window.__lark_*` global shortcuts cover Framework / State / Router / Frame / View and HMR helpers.
45
+ Fifth, debug-friendly. `installFrameDevtoolBridge` exposes the Frame tree to Devtool via `postMessage`, enabling real-time inspection of the view hierarchy.
45
46
 
46
47
  Not suitable for: projects requiring SSR/streaming rendering, cross-platform needs like React Native, or projects needing off-the-shelf Chrome extension panels. For those, consider the React or Vue ecosystems.
47
48
 
@@ -91,7 +92,20 @@ export default {
91
92
 
92
93
  Two important notes: the `loader` field must be imported as a value (`loader: larkMvcLoader`), not as a string name; you must use `exclude: /index\.html$/` to let `HtmlWebpackPlugin` handle the entry HTML, otherwise it will be compiled as a template.
93
94
 
94
- Both integrations share the same compilation pipeline: `extractGlobalVars` extracts external variables referenced in the template, and `compileTemplate` produces an ES module that imports helper functions from `@lark.js/mvc/runtime`.
95
+ ### Rspack
96
+
97
+ ```js
98
+ // rspack.config.mjs
99
+ import { LarkMvcPlugin } from "@lark.js/mvc/rspack";
100
+
101
+ export default {
102
+ plugins: [new LarkMvcPlugin()],
103
+ };
104
+ ```
105
+
106
+ The Rspack integration mirrors the Webpack plugin API. The loader returns a `Promise<string>` directly (Rspack async loader convention) rather than calling `this.callback()`.
107
+
108
+ All three integrations share the same compilation pipeline: `extractGlobalVars` extracts external variables referenced in the template, and `compileTemplate` produces an ES module that imports helper functions from `@lark.js/mvc/runtime`. HMR snippets are auto-injected by each plugin at compile time (see the HMR section below).
95
109
 
96
110
  ## Five-Minute Quick Start
97
111
 
@@ -119,18 +133,18 @@ Both integrations share the same compilation pipeline: `extractGlobalVars` extra
119
133
  // src/view.ts
120
134
  import { defineView, Router } from "@lark.js/mvc";
121
135
 
122
- export default defineView({
123
- make() {
124
- this.updater.set({ appName: "My App" });
125
- this.on("destroy", () => console.log(`view destroyed: ${this.id}`));
126
- },
127
- navigate(path: string, params?: Record<string, unknown>) {
128
- Router.to(path, params);
129
- },
130
- });
136
+ export function withBaseView<P extends Record<string, unknown>>(
137
+ setup: (ctx: import("@lark.js/mvc").ViewCtx, params?: unknown) => P,
138
+ ) {
139
+ return defineView((ctx, params) => {
140
+ ctx.updater.set({ appName: "My App" });
141
+ ctx.on("destroy", () => console.log(`view destroyed: ${ctx.id}`));
142
+ return setup(ctx, params);
143
+ });
144
+ }
131
145
  ```
132
146
 
133
- `defineView` is a typed wrapper around `View.extend`: via `ThisType<P & ViewInterface>` it threads the literal's own fields into `this`, so writing `this.appName` in `make` requires no cast. Runtime behavior is equivalent to `View.extend({...})`.
147
+ `defineView(setup)` is the functional API for defining views. The setup function receives a `ViewCtx` and returns `{ template, events, assign? }`. No `this` binding all framework APIs are accessed via `ctx`.
134
148
 
135
149
  ### View and Template
136
150
 
@@ -156,21 +170,17 @@ export default defineView({
156
170
 
157
171
  ```ts
158
172
  // src/views/home.ts
159
- import { bindStore } from "@lark.js/mvc";
160
- import View from "../view";
173
+ import { defineView, bindStore } from "@lark.js/mvc";
161
174
  import template from "./home.html";
162
175
  import useCountStore from "../store/count";
163
176
 
164
- export default View.extend({
165
- template,
166
- init() {
167
- this.assign();
168
- bindStore(this, useCountStore, (s) => ({ count: s.count }));
169
- },
170
- assign() {
171
- this.updater.snapshot();
177
+ export default defineView((ctx) => {
178
+ bindStore(ctx, useCountStore, (s) => ({ count: s.count }));
179
+
180
+ const assign = (): boolean | undefined => {
181
+ ctx.updater.snapshot();
172
182
  const { count } = useCountStore.getState();
173
- this.updater.set({
183
+ ctx.updater.set({
174
184
  title: "Home",
175
185
  count,
176
186
  items: [
@@ -178,14 +188,21 @@ export default View.extend({
178
188
  { id: "b", name: "Beta" },
179
189
  ],
180
190
  });
181
- return this.updater.altered();
182
- },
183
- render() {
184
- this.updater.digest();
185
- },
186
- "incr<click>"() {
187
- useCountStore.getState().increment();
188
- },
191
+ return ctx.updater.altered();
192
+ };
193
+
194
+ // Call assign for initial render
195
+ assign();
196
+
197
+ return {
198
+ template,
199
+ assign,
200
+ events: {
201
+ "incr<click>": () => {
202
+ useCountStore.getState().increment();
203
+ },
204
+ },
205
+ };
189
206
  });
190
207
  ```
191
208
 
@@ -193,15 +210,15 @@ export default View.extend({
193
210
 
194
211
  ```ts
195
212
  // src/boot.ts
196
- import { Framework, registerViewClass, View } from "@lark.js/mvc";
213
+ import { Framework, registerViewClass } from "@lark.js/mvc";
197
214
  import type { FrameworkConfig } from "@lark.js/mvc";
198
215
  import HomeView from "./views/home";
199
216
  import AboutView from "./views/about";
200
217
  import NotFoundView from "./views/404";
201
218
 
202
- registerViewClass("home", HomeView as typeof View);
203
- registerViewClass("about", AboutView as typeof View);
204
- registerViewClass("404", NotFoundView as typeof View);
219
+ registerViewClass("home", HomeView);
220
+ registerViewClass("about", AboutView);
221
+ registerViewClass("404", NotFoundView);
205
222
 
206
223
  const config: FrameworkConfig = {
207
224
  rootId: "app",
@@ -220,7 +237,7 @@ const config: FrameworkConfig = {
220
237
  Framework.boot(config);
221
238
  ```
222
239
 
223
- `Framework.boot()` executes the following steps in order (order is correctness-sensitive): merge user config (including `routeMode`), inject config into Router (which determines history/hash mode), set EventDelegator's frame getter, subscribe to Router/State `changed` events, mark Framework/Router/State as booted, install the Frame Devtool Bridge, create the root Frame via `Frame.createRoot(config.rootId)`, call `Router._bind()` to bind route events (poptate for history mode, hashchange + popstate for hash mode) and trigger the first `diff()`, and finally mount `defaultView` if Router has not mounted a view. Step seven must precede step eight because the first `diff()` may immediately trigger `CHANGED` followed by `Frame.getRoot()`, and if the root Frame does not exist it degrades to rendering against the wrong element.
240
+ `Framework.boot()` executes the following steps in order (order is correctness-sensitive): merge user config (including `routeMode`), inject config into Router (which determines history/hash mode), set EventDelegator's frame getter, subscribe to Router/State `changed` events, mark Framework/Router/State as booted, install the Frame Devtool Bridge, create the root Frame via `Frame.createRoot(config.rootId)`, call `Router._bind()` to bind route events (popstate for history mode, hashchange + popstate for hash mode) and trigger the first `diff()`, and finally mount `defaultView` only if Router did not already initiate a mount. The guard checks `!rootFrame.viewPath` (set synchronously at the start of `mountView`) rather than `!rootFrame.view` (the `viewInstance`, which is only assigned after async view-class loading completes). Checking `viewInstance` loses the race when views load via dynamic `import()`: the Router triggers `mountView("counter")` during `_bind()`, but `viewInstance` is still `undefined` when the boot guard runs, so `defaultView` mounts in parallel and wins the signature race. Step seven must precede step eight because the first `diff()` may immediately trigger `CHANGED` followed by `Frame.getRoot()`, and if the root Frame does not exist it degrades to rendering against the wrong element.
224
241
 
225
242
  ## Three Data Pipelines: Updater / State / Store
226
243
 
@@ -231,8 +248,8 @@ Lark provides three data flow mechanisms simultaneously, ranging from simple to
231
248
  `Updater` is each View's local data manager. All intra-view data flow ultimately goes through the Updater:
232
249
 
233
250
  ```ts
234
- this.updater.set({ count: newCount });
235
- this.updater.digest();
251
+ ctx.updater.set({ count: newCount });
252
+ ctx.updater.digest();
236
253
  ```
237
254
 
238
255
  Full pipeline: `updater.set(data)` shallow-merges data into the internal data object and collects changed keys. `updater.digest()` calls the compiled template function to generate an HTML string. `domGetNode` uses `tmp.innerHTML = wrap + html` to parse it into temporary DOM. `domSetChildNodes` compares against the live DOM to produce a keyed diff. DOM operations are applied in batch. `endUpdate()` notifies child Frames to complete mounting.
@@ -250,20 +267,24 @@ State.set({ pageTitle: "Home", isLoggedIn: true });
250
267
  State.digest();
251
268
  ```
252
269
 
253
- Subscription has two approaches. First, declare `observeState` in a view, and the framework automatically re-renders when the corresponding keys change:
270
+ Subscription has two approaches. First, declare `observeState` in a view setup, and the framework automatically re-renders when the corresponding keys change:
254
271
 
255
272
  ```ts
256
- export default View.extend({
257
- template,
258
- observeState: "pageTitle,isLoggedIn",
259
- assign() {
260
- this.updater.snapshot();
261
- this.updater.set({
262
- title: State.get("pageTitle"),
263
- logged: State.get("isLoggedIn"),
264
- });
265
- return this.updater.altered();
266
- },
273
+ import { defineView } from "@lark.js/mvc";
274
+
275
+ export default defineView((ctx) => {
276
+ ctx.observeState("pageTitle,isLoggedIn");
277
+ return {
278
+ template,
279
+ assign: () => {
280
+ ctx.updater.snapshot();
281
+ ctx.updater.set({
282
+ title: State.get("pageTitle"),
283
+ logged: State.get("isLoggedIn"),
284
+ });
285
+ return ctx.updater.altered();
286
+ },
287
+ };
267
288
  });
268
289
  ```
269
290
 
@@ -275,12 +296,14 @@ State.on("changed", (e) => {
275
296
  });
276
297
  ```
277
298
 
278
- State manages lifecycle through reference counting on keys. Best practice is to add a `State.clean` mixin to all consumers, ensuring that when the last observer is destroyed, the key is automatically reclaimed:
299
+ State manages lifecycle through reference counting on keys. Use `State.clean("keys")` to create a cleanup function that automatically reclaims keys when the last observer is destroyed:
279
300
 
280
301
  ```ts
281
- export default View.extend({
282
- mixins: [State.clean("pageTitle,isLoggedIn")],
283
- template,
302
+ import { defineView, State } from "@lark.js/mvc";
303
+
304
+ export default defineView((ctx) => {
305
+ State.clean("pageTitle,isLoggedIn")(ctx);
306
+ return { template, events: {} };
284
307
  });
285
308
  ```
286
309
 
@@ -288,11 +311,11 @@ Without cleanup, keys persist on global State causing leaks.
288
311
 
289
312
  ### Store: Zustand-Style State Management
290
313
 
291
- The Store API aligns with zustand's design: `create(name, (set, get) => body)` returns a `StoreApi` object providing `getState` / `setState` / `subscribe` / `destroy`. State is a plain object with no Proxy; all writes must go through `setState` or actions. `bindStore(view, store, selector?)` binds a store to a Lark View with automatic unsubscription on view destruction.
314
+ The Store API aligns with zustand's design: `createStore(name, (set, get) => body)` returns a `StoreApi` object providing `getState` / `setState` / `subscribe` / `destroy`. State is a plain object with no Proxy; all writes must go through `setState` or actions. `bindStore(view, store, selector?)` binds a store to a Lark View with automatic unsubscription on view destruction.
292
315
 
293
316
  ```ts
294
317
  // src/store/count.ts
295
- import { create, computed } from "@lark.js/mvc";
318
+ import { createStore, computed } from "@lark.js/mvc";
296
319
 
297
320
  interface CountStore {
298
321
  count: number;
@@ -304,7 +327,7 @@ interface CountStore {
304
327
  reset: () => void;
305
328
  }
306
329
 
307
- const useCountStore = create<CountStore>("count", (set, get) => ({
330
+ const useCountStore = createStore<CountStore>("count", (set, get) => ({
308
331
  count: 0,
309
332
  step: 1,
310
333
  doubled: computed(["count"], () => get().count * 2),
@@ -328,7 +351,7 @@ const useCountStore = create<CountStore>("count", (set, get) => ({
328
351
  export default useCountStore;
329
352
  ```
330
353
 
331
- The creator function receives `(set, get)` and executes once during `create`. Lark iterates the return value: functions become actions (attached to state, unaffected by `setState`); `computed(deps, fn)` occupies a derived slot, running `fn()` once for the initial value and recomputing whenever any dep key changes via `setState`; all other fields become initial state. Writing to a computed key via `setState` is silently ignored.
354
+ The creator function receives `(set, get)` and executes once during `createStore`. Lark iterates the return value: functions become actions (attached to state, unaffected by `setState`); `computed(deps, fn)` occupies a derived slot, running `fn()` once for the initial value and recomputing whenever any dep key changes via `setState`; all other fields become initial state. Writing to a computed key via `setState` is silently ignored.
332
355
 
333
356
  Reading and writing state:
334
357
 
@@ -347,35 +370,41 @@ useCountStore.getState().increment();
347
370
  Binding in a view:
348
371
 
349
372
  ```ts
350
- import { bindStore } from "@lark.js/mvc";
373
+ import { defineView, bindStore } from "@lark.js/mvc";
351
374
 
352
- export default View.extend({
353
- template,
354
- init() {
355
- // Bind all non-function state keys to view updater; auto-unsubscribes on destroy
356
- bindStore(this, useCountStore);
375
+ export default defineView((ctx) => {
376
+ // Bind all non-function state keys to view updater; auto-unsubscribes on destroy
377
+ bindStore(ctx, useCountStore);
357
378
 
358
- // Or use a selector to sync only specific keys
359
- bindStore(this, useCountStore, (s) => ({ count: s.count }));
360
- },
361
- "increment<click>"() {
362
- useCountStore.getState().increment();
363
- },
379
+ // Or use a selector to sync only specific keys
380
+ bindStore(ctx, useCountStore, (s) => ({ count: s.count }));
381
+
382
+ return {
383
+ template,
384
+ events: {
385
+ "increment<click>": () => {
386
+ useCountStore.getState().increment();
387
+ },
388
+ },
389
+ };
364
390
  });
365
391
  ```
366
392
 
367
393
  Custom subscription callback (when data transformation is needed before sync):
368
394
 
369
395
  ```ts
370
- init() {
396
+ import { defineView } from "@lark.js/mvc";
397
+
398
+ export default defineView((ctx) => {
371
399
  const syncToView = () => {
372
400
  const s = useCountStore.getState();
373
- this.updater.digest({ count: s.count, isPositive: s.count > 0 });
401
+ ctx.updater.digest({ count: s.count, isPositive: s.count > 0 });
374
402
  };
375
403
  const off = useCountStore.subscribe(syncToView);
376
- this.on("destroy", off);
404
+ ctx.on("destroy", off);
377
405
  syncToView();
378
- }
406
+ return { template, events: {} };
407
+ });
379
408
  ```
380
409
 
381
410
  Destroying a store:
@@ -400,55 +429,38 @@ Selection guide: start with State; upgrade to Store when you need actions, deriv
400
429
 
401
430
  ## View Definition and Lifecycle
402
431
 
403
- ### Two Definition Approaches
432
+ ### Definition
404
433
 
405
- `View.extend({...})` is the low-level primitive approach where all mixins, event methods, and lifecycle hooks are declared in the passed object:
406
-
407
- ```ts
408
- import { View } from "@lark.js/mvc";
409
-
410
- export default View.extend({
411
- template,
412
- init() {
413
- /* ... */
414
- },
415
- assign() {
416
- /* ... */
417
- },
418
- render() {
419
- /* ... */
420
- },
421
- });
422
- ```
423
-
424
- `defineView({...})` is a typed wrapper that threads the literal's own fields into `this` via `ThisType<P & ViewInterface>`:
434
+ `defineView(setup)` is the functional API for defining views. The setup function receives a `ViewCtx` and optional init params, and returns a descriptor with `template`, `events`, and optional `assign`:
425
435
 
426
436
  ```ts
427
437
  import { defineView } from "@lark.js/mvc";
428
438
 
429
- export default defineView({
430
- customField: "x",
431
- init() {
432
- console.log(this.customField);
433
- },
439
+ export default defineView((ctx, params) => {
440
+ // Setup runs once on mount. Hooks (useState, useEffect, useStore)
441
+ // can be called here to manage state and side effects.
442
+ return {
443
+ template,
444
+ events: { "btn<click>": () => {} },
445
+ assign: (options?: unknown) => ctx.updater.altered(),
446
+ };
434
447
  });
435
448
  ```
436
449
 
437
- Both produce equivalent runtime artifacts; the difference is purely in TypeScript inference.
450
+ No `this` binding all framework APIs are accessed via `ctx`.
438
451
 
439
452
  ### Lifecycle
440
453
 
441
- - `init(params?)` — Called when the view is first instantiated. `params` comes from query strings on `v-lark`. Read stores and call `this.assign()` to prepare initial data here.
442
- - `make()` — Called by the merged makes pipeline; each mixin's `make` executes in order. Suitable for "run once per instance" initialization.
443
- - `assign()` — Should be called when data may have changed. Pattern: `this.updater.snapshot()` at the top, `this.updater.set(...)` in the middle, `return this.updater.altered()` at the end. The framework uses `altered()` to determine whether re-render is needed.
444
- - `render()` — Default implementation is `this.updater.digest()`. Wrapped by `View.wrapMethod`: increments signature on entry, handles pending endUpdate cleanup on exit.
454
+ - **Setup function** — Called once on mount with `(ctx, params)`. `params` comes from query strings on `v-lark`. Read stores, set initial data, and register hooks here.
455
+ - `assign(options?)` — Called when data may have changed. Pattern: `ctx.updater.snapshot()` at the top, `ctx.updater.set(...)` in the middle, `return ctx.updater.altered()` at the end.
456
+ - `ctx.render()` — Default implementation is `ctx.updater.digest()`. Can be wrapped via `ctx.renderMethod`.
445
457
  - Destruction — The framework automatically calls `release(key, true)` to release all `capture`d resources, cleans up event delegation, and sets signature to 0.
446
458
 
447
- `view.signature` marks async operation validity: greater than 0 means the view is alive (incremented on each render), 0 means destroyed. Never modify it manually.
459
+ `ctx.signature` marks async operation validity: greater than 0 means the view is alive (incremented on each render), 0 means destroyed. Never modify it manually.
448
460
 
449
461
  ### Event Methods
450
462
 
451
- Event methods are named `name<eventType>` or `$selector<eventType>`. `View.prepare` scans the prototype at class definition time, parsing methods into three maps (`$evtObjMap` / `$selMap` / `$globalEvtList`) written to the prototype, managed at runtime by `EventDelegator`.
463
+ Event methods are named `name<eventType>` or `$selector<eventType>` and declared in the `events` map returned by the setup function. `registerEvents(ctx)` processes the map at mount time, binding events via `EventDelegator`.
452
464
 
453
465
  | Syntax | Meaning |
454
466
  | -------------------------- | -------------------------------------------------- |
@@ -466,11 +478,11 @@ Event delegation implementation: `EventDelegator` attaches listeners on `documen
466
478
 
467
479
  ### Resource Management
468
480
 
469
- `capture` registers "destroyable objects tied to the view lifecycle":
481
+ `ctx.capture(key, resource, destroyOnRender?)` registers "destroyable objects tied to the view lifecycle":
470
482
 
471
483
  ```ts
472
484
  const timer = setInterval(tick, 1000);
473
- this.capture(
485
+ ctx.capture(
474
486
  "myTimer",
475
487
  {
476
488
  destroy() {
@@ -481,20 +493,18 @@ this.capture(
481
493
  );
482
494
  ```
483
495
 
484
- The third parameter `destroyOnRender` when `true` causes automatic destruction and removal on the next render call; when `false` cleanup happens only on view destruction. `release(key, destroy = true)` manually removes an entry.
496
+ The third parameter `destroyOnRender` when `true` causes automatic destruction and removal on the next render call; when `false` cleanup happens only on view destruction. `ctx.release(key, destroy = true)` manually removes an entry.
485
497
 
486
498
  ### Async Safety
487
499
 
488
- Async callbacks may arrive after a view has re-rendered or been destroyed. `wrapAsync` adds a signature check layer:
500
+ Async callbacks may arrive after a view has re-rendered or been destroyed. `ctx.wrapAsync(fn)` adds a signature check layer:
489
501
 
490
502
  ```ts
491
- async loadData() {
492
- const safe = this.wrapAsync((data: unknown) => {
493
- this.updater.set({ items: data }).digest();
494
- });
495
- const data = await fetch("/api/items").then((r) => r.json());
496
- safe(data); // Will not execute if view has re-rendered or been destroyed
497
- }
503
+ const loadData = ctx.wrapAsync((data: unknown) => {
504
+ ctx.updater.set({ items: data }).digest();
505
+ });
506
+ const data = await fetch("/api/items").then((r) => r.json());
507
+ loadData(data); // Will not execute if view has re-rendered or been destroyed
498
508
  ```
499
509
 
500
510
  `mark(host, key)` / `unmark(host)` is the lower-level equivalent mechanism: returns a `() => boolean` validator. All mark state is stored in a module-level `WeakMap` rather than polluting the host object, so it works on `Object.freeze`d objects.
@@ -560,21 +570,23 @@ Guards execute in registration order. Any guard that returns/resolves to `false`
560
570
 
561
571
  ### useUrlState: URL Parameter State Sync
562
572
 
563
- `useUrlState(view, initialState?)` reads URL query parameters into a state object and provides a `setState` function that writes changes back to the URL (via `Router.to()`). It automatically observes the specified parameter keys, re-rendering the view when the URL changes.
573
+ `useUrlState(ctx, initialState?)` reads URL query parameters into a state object and provides a `setState` function that writes changes back to the URL (via `Router.to()`). It automatically observes the specified parameter keys, re-rendering the view when the URL changes.
564
574
 
565
575
  ```ts
566
- import { useUrlState } from "@lark.js/mvc";
567
-
568
- export default View.extend({
569
- template,
570
- init() {
571
- const [state, setState] = useUrlState(this, { page: "1", size: "20" });
572
- this.updater.set({ page: state.page, size: state.size }).digest();
573
- this.setPageState = setState;
574
- },
575
- "nextPage<click>"() {
576
- this.setPageState((prev) => ({ page: String(Number(prev.page) + 1) }));
577
- },
576
+ import { defineView, useUrlState } from "@lark.js/mvc";
577
+
578
+ export default defineView((ctx) => {
579
+ const [state, setPageState] = useUrlState(ctx, { page: "1", size: "20" });
580
+ ctx.updater.set({ page: state.page, size: state.size }).digest();
581
+
582
+ return {
583
+ template,
584
+ events: {
585
+ "nextPage<click>": () => {
586
+ setPageState((prev) => ({ page: String(Number(prev.page) + 1) }));
587
+ },
588
+ },
589
+ };
578
590
  });
579
591
  ```
580
592
 
@@ -584,19 +596,17 @@ Supports both history and hash routing modes.
584
596
 
585
597
  `Service` is a request management layer built on `fetch` (or any synchronous function) with built-in LFU caching, concurrent deduplication, serial queuing, and lifecycle events.
586
598
 
587
- ### Defining Subclasses and Endpoints
599
+ ### Defining Services and Endpoints
588
600
 
589
601
  ```ts
590
- import { Service, type Payload } from "@lark.js/mvc";
602
+ import { createService, type PayloadApi } from "@lark.js/mvc";
591
603
 
592
- const AppService = Service.extend(
593
- (payload, callback) => {
604
+ const AppService = createService(
605
+ (payload: PayloadApi, callback: () => void) => {
594
606
  fetch(payload.get<string>("url"), {
595
607
  method: payload.get<string>("method") || "GET",
596
608
  headers: { "Content-Type": "application/json" },
597
- body: payload.get("data")
598
- ? JSON.stringify(payload.get("data"))
599
- : undefined,
609
+ body: payload.get("data") ? JSON.stringify(payload.get("data")) : undefined,
600
610
  })
601
611
  .then((r) => r.json())
602
612
  .then((data) => {
@@ -616,10 +626,7 @@ AppService.add([
616
626
  url: "/api/users/:id",
617
627
  cache: 30_000,
618
628
  before(payload) {
619
- payload.set(
620
- "url",
621
- payload.get<string>("url").replace(":id", payload.get<string>("id")),
622
- );
629
+ payload.set("url", payload.get<string>("url").replace(":id", payload.get<string>("id")));
623
630
  },
624
631
  after(payload) {
625
632
  const data = payload.get("data");
@@ -633,21 +640,25 @@ AppService.add([
633
640
  ### Using in Views
634
641
 
635
642
  ```ts
636
- export default View.extend({
637
- template,
638
- init() {
639
- const service = new AppService();
640
- this.capture("userService", service, true);
641
- this.service = service;
642
- this.loadData();
643
- },
644
- loadData() {
645
- this.service.all("userList", (errors, payload) => {
643
+ import { defineView } from "@lark.js/mvc";
644
+ import template from "./users.html";
645
+ import { AppService } from "../service/app";
646
+
647
+ export default defineView((ctx) => {
648
+ const service = AppService.instance();
649
+ ctx.capture("userService", service, true);
650
+
651
+ const loadData = (): void => {
652
+ service.all("userList", (errors, payload) => {
646
653
  if (!errors[0]) {
647
- this.updater.set({ users: payload.get("data") }).digest();
654
+ ctx.updater.set({ users: payload.get("data") }).digest();
648
655
  }
649
656
  });
650
- },
657
+ };
658
+
659
+ loadData();
660
+
661
+ return { template, events: {} };
651
662
  });
652
663
  ```
653
664
 
@@ -684,11 +695,10 @@ Template files use the `.html` extension and are compiled at build time by `lark
684
695
  ### Control Flow
685
696
 
686
697
  ```html
687
- {{if condition}}...{{else if other}}...{{else}}...{{/if}} {{forOf list as item}}
688
- ... {{/forOf}} {{forOf list as item idx}} {{=idx}}: {{=item.name}} {{/forOf}}
689
- {{forOf list as {name, age} idx last first}} ... {{/forOf}} {{forIn object as
690
- value key}} ... {{/forIn}} {{for (let i = 0; i < n; i++)}} ... {{/for}} {{set
691
- localVar = expr}}
698
+ {{if condition}}...{{else if other}}...{{else}}...{{/if}} {{forOf list as item}} ... {{/forOf}}
699
+ {{forOf list as item idx}} {{=idx}}: {{=item.name}} {{/forOf}} {{forOf list as {name, age} idx last
700
+ first}} ... {{/forOf}} {{forIn object as value key}} ... {{/forIn}} {{for (let i = 0; i < n; i++)}}
701
+ ... {{/for}} {{set localVar = expr}}
692
702
  ```
693
703
 
694
704
  `forOf` requires the `as` keyword. `{{forOf list item}}` is a compile-time error; the correct form is `{{forOf list as item}}`.
@@ -725,41 +735,51 @@ Marking large static subtrees with `ldk` can completely skip rendering work. Thi
725
735
 
726
736
  ## Frame and the View Tree
727
737
 
728
- `Frame` manages view mounting and unmounting, maintains parent-child relationships, and provides cross-view method invocation. Each Frame corresponds to one DOM container and one View instance.
738
+ `Frame` manages view mounting and unmounting, maintains parent-child relationships, and provides cross-view method invocation. Each Frame corresponds to one DOM container and one `ViewCtx`.
729
739
 
730
740
  ### Typed API
731
741
 
732
- | API | Description |
733
- | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
734
- | `Frame.get(id)` | Look up Frame by DOM id |
735
- | `Frame.getAll()` | All Frames as `Map<string, Frame>` |
736
- | `Frame.getRoot()` | Current root Frame; returns `undefined` if not created |
737
- | `Frame.createRoot(id)` | Idempotent root creation (`Framework.boot` calls this) |
738
- | `new Frame(containerId)` | Independent Frame instance for micro-frontend / embedded widget scenarios |
739
- | `frame.invoke(name, args?)` | Call the owning view's method; if view not mounted, pushes to `invokeList`, flushed by `View.runInvokes(frame)` after mounting |
740
- | `frame.children()` | Child Frame id array (order not guaranteed) |
741
- | `frame.parent(level?)` | Ancestor Frame, defaults to one level up |
742
- | `frame.mountFrame(id, viewPath, params?)` | Explicitly create a child Frame |
743
- | `frame.unmountFrame(id)` | Unmount a specific child Frame |
744
- | `frame.mountZone(id?)` / `frame.unmountZone(id?)` | Batch mount/unmount all `v-lark` child nodes in a zone |
745
- | `Frame.on("add" \| "remove", handler)` | Frame instance lifecycle events (static emitter) |
746
- | `frame.on("created" \| "alter", handler)` | All child Frames rendered / child content changed (instance emitter) |
747
-
748
- Frame instances enter `frameCache` object pool upon destruction, caching up to `MAX_FRAME_POOL = 64`; beyond that threshold they are GC'd. Do not retain Frame references after unmounting as the object may be reused.
742
+ | API | Description |
743
+ | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
744
+ | `Frame.get(id)` | Look up Frame by DOM id |
745
+ | `Frame.getAll()` | All Frames as `Map<string, Frame>` |
746
+ | `Frame.getRoot()` | Current root Frame; returns `undefined` if not created |
747
+ | `Frame.createRoot(id)` | Idempotent root creation (`Framework.boot` calls this) |
748
+ | `createFrame(id, parentId?)` | Create an independent Frame instance for micro-frontend / embedded widget scenarios |
749
+ | `frame.invoke(name, args?)` | Call the owning view's method; if view not mounted, pushes to `invokeList`, flushed by `runInvokes(frame)` after mounting |
750
+ | `frame.children()` | Child Frame id array (order not guaranteed) |
751
+ | `frame.parent(level?)` | Ancestor Frame, defaults to one level up |
752
+ | `frame.mountFrame(id, viewPath, params?)` | Explicitly create a child Frame |
753
+ | `frame.unmountFrame(id)` | Unmount a specific child Frame |
754
+ | `frame.mountZone(id?)` / `frame.unmountZone(id?)` | Batch mount/unmount all `v-lark` child nodes in a zone |
755
+ | `Frame.on("add" \| "remove", handler)` | Frame instance lifecycle events (static emitter) |
756
+ | `frame.on("created" \| "alter", handler)` | All child Frames rendered / child content changed (instance emitter) |
757
+
758
+ Frame instances are plain objects created by `createFrame()`. There is no object pool destroyed frames are removed from the registry and GC'd. Do not retain Frame references after `unmountFrame` as the frame may be stale.
749
759
 
750
760
  ## Module Federation Micro-Frontend
751
761
 
752
- Lark treats Module Federation as a first-class citizen, providing two integration modes.
762
+ Lark provides first-class micro-frontend support through Webpack Module Federation (or Rspack's MF implementation). The integration centers on three pieces: `FrameworkConfig.require` (the bridge to MF container APIs), `use()` (Lark's module loader that delegates to `require`), and `CrossSite` (a skeleton-screen bridge view for remote loading).
763
+
764
+ ### How it works: the loading pipeline
765
+
766
+ When a template contains `v-lark="remote-app/views/home"`, the following sequence executes:
767
+
768
+ 1. **`Frame.mountView(viewPath)`** parses the view path and checks the view registry (`getViewClass`). If the view setup is already registered (sync path), it mounts immediately. If not, it enters the async path.
753
769
 
754
- ### Mode 1: Direct Async Loading
770
+ 2. **`use(viewClassName, callback)`** is called. This function in `module-loader.ts` checks whether `FrameworkConfig.require` is configured:
771
+ - **MF mode**: delegates to `config.require(names)`, which calls `__webpack_init_sharing__` / `__webpack_share_scopes__` to initialize the shared scope, then resolves the remote container via `window[remoteName]`, calls `container.init(scope)`, and `container.get(modulePath)` to obtain the factory. The factory returns the module, and `use()` extracts the default export.
772
+ - **Fallback mode**: if `require` is not configured, `use()` falls back to dynamic `import()` for ESM-based loading.
755
773
 
756
- Via `FrameworkConfig.require`, resolve unregistered view paths to remote modules:
774
+ 3. **Signature guard**: `mountView` captures `frame.signature` before the async load begins. When the callback fires, it checks `sign !== frame.signature` — if the frame was unmounted or re-mounted during the async wait, the callback silently returns, preventing stale mounts.
775
+
776
+ 4. **Registration and mount**: the loaded setup function is registered via `registerViewClass(viewClassName, setup)`, then `mountCtx(frame, setup, params)` creates the `ViewCtx`, runs the setup function, and renders.
757
777
 
758
778
  ```ts
759
779
  Framework.boot({
760
780
  rootId: "app",
761
781
  projectName: "host-app",
762
- crossConfigs: [
782
+ crossSites: [
763
783
  {
764
784
  projectName: "remote-app",
765
785
  source: "remote_app@//cdn.example.com/remote-app/remoteEntry.js",
@@ -787,7 +807,7 @@ Framework.boot({
787
807
 
788
808
  Then write `v-lark="remote-app/views/home"` in templates to trigger async loading and mounting of the remote view.
789
809
 
790
- ### Mode 2: CrossSite Bridge View
810
+ ### CrossSite bridge view
791
811
 
792
812
  For skeleton screens and remote `prepare` hooks, use `CrossSite`:
793
813
 
@@ -800,9 +820,17 @@ registerViewClass("cross-site", CrossSite);
800
820
  <div v-lark="cross-site?view=remote-app/views/home&bizCode=mybiz"></div>
801
821
  ```
802
822
 
803
- CrossSite first renders as a normal view showing a skeleton (default `Loading...`, overridable via `skeleton` parameter) and occupies a `<div id="mf_${viewId}">` sub-container. `updateView()` uses `++this.$sign` to get a sequence number, loads the remote prepare module via `use(projectName/prepare)` and executes it. Race guard: if after loading `this.$sign !== sign`, return immediately (user navigated away). If the same view path as last time and the remote view exposes an `assign` method, it calls `assign` + `render` in-place to reuse the existing view; otherwise it calls `owner.mountFrame('mf_' + this.id, this.$view, this.$params)` to actually mount.
823
+ `CrossSite` is a `ViewSetup` function that:
824
+
825
+ 1. Renders a skeleton placeholder (default `Loading…`, overridable via the `skeleton` parameter).
826
+ 2. Parses the `view` parameter to extract the project name (`remote-app`) and remote path (`views/home`).
827
+ 3. Loads the remote project's `prepare` module via `use("remote-app/prepare", callback)`. The `prepare` module can initialize shared state, register global services, or configure the remote project's API host.
828
+ 4. After `prepare` completes, loads the actual remote view via `use(viewPath, callback)`.
829
+ 5. Mounts the remote view in a sub-frame via `ctx.owner.mountFrame("mf_" + ctx.id, viewPath, { bizCode, project })`.
830
+ 6. Uses a signature counter (`state.sign`) for race condition guards: if the user navigates away during loading, `state.sign` is incremented on destroy, and the pending load callbacks check `currentSign !== state.sign` to bail out.
831
+ 7. Re-renders with a hidden container once the remote view is mounted, hiding the skeleton.
804
832
 
805
- ### Webpack Configuration
833
+ ### Webpack / Rspack configuration
806
834
 
807
835
  Host:
808
836
 
@@ -827,26 +855,90 @@ new ModuleFederationPlugin({
827
855
  });
828
856
  ```
829
857
 
830
- `@lark.js/mvc` must be `singleton: true`; otherwise host and remote hold different View/Frame class instances and all `instanceof` checks fail across boundaries.
858
+ `@lark.js/mvc` must be `singleton: true` because the framework's module-level singletons (`State`, `Router`, `Frame`, `EventDelegator`, `config`) must be shared across host and remote. Without `singleton`, each bundle gets its own copy of these singletons, causing state divergence — e.g., `State.set()` in the host would not notify views in the remote, and `registerViewClass()` in the host would not be visible to the remote's `Frame.mountView()`.
831
859
 
832
860
  `splitChunks.chunks` must be `"async"`. Using `"all"` extracts `@lark.js/mvc` into a separate vendor chunk, breaking MF shared scope initialization (`ScriptExternalLoadError: Loading script failed`).
833
861
 
834
- ## Debugging and Devtool Bridge
862
+ ## Hot Module Replacement (HMR)
863
+
864
+ Lark ships zero-config HMR for Vite, Webpack, and Rspack. When you edit a `.html` template or a `.ts` view file, the framework hot-swaps the changed code in place without a full page reload. View-local state (counter values, form input, scroll-derived data) survives the update.
865
+
866
+ ### Zero-config auto-injection
867
+
868
+ The build plugins inject HMR code at compile time, similar to `@vitejs/plugin-react` (React Refresh) and `@vitejs/plugin-vue` (Vue HMR). You add the plugin to your config and HMR works. No per-file `import.meta.hot` boilerplate needed.
869
+
870
+ The Vite plugin uses two hooks: `load` appends a template HMR snippet to compiled `.html` modules, and `transform` injects a view class HMR snippet into `.ts` files that import `.html`. Webpack and Rspack loaders do the same for template modules.
871
+
872
+ ### Two HMR layers
873
+
874
+ **Template layer** (`.html` changes): The compiled template module self-accepts. The accept callback calls `hotSwapByTemplate(oldTemplate, newTemplate)` to find every mounted view whose `ctx.getTemplate()` returns the old function reference, replace it via `ctx.setTemplate(newTemplate)`, and force-render. Event handlers are NOT re-delegated because they live in the `events` map returned by the setup function, not in the template.
875
+
876
+ **View setup layer** (`.ts` changes): The plugin rewrites `export default defineView(...)` to `const __larkViewDefault = defineView(...)` and appends an HMR snippet. The accept callback calls `hotSwapByView(oldSetup, newSetup)` which updates the registry and hot-swaps every matching frame via `hotSwapView`.
835
877
 
836
- ### Global Objects
878
+ ### State preservation: in-place setup re-run
837
879
 
838
- After `Framework.boot` completes, the following are attached to `window`:
880
+ `hotSwapView(frame, newSetup)` preserves the view context entirely:
839
881
 
840
- | Global | Value | Purpose |
841
- | ------------------------------------ | ---------------- | ------------------------------ |
842
- | `window.__lark_Framework` | Framework object | Direct access |
843
- | `window.__lark_State` | State object | Direct access |
844
- | `window.__lark_Router` | Router object | Direct access |
845
- | `window.__lark_Frame` | Frame class | Direct access |
846
- | `window.__lark_View` | View class | Direct access |
847
- | `window.__lark_registerViewClass` | Function | HMR: re-register View class |
848
- | `window.__lark_invalidateViewClass` | Function | HMR: remove View from registry |
849
- | `window.__lark_getViewClassRegistry` | Function | HMR: read registry |
882
+ 1. Run old cleanups (`useEffect` cleanup functions)
883
+ 2. Unregister old events
884
+ 3. Destroy `destroyOnRender` resources
885
+ 4. Re-run `newSetup(ctx)` — ctx instance, `updater`, `updater.data`, `resources`, `emitter`, `signature`, `id`, and `owner` all stay the same
886
+ 5. Update template, events, and assign from the new descriptor
887
+ 6. Register new events
888
+ 7. `ctx.signature++; ctx.fire("render"); destroyAllResources(ctx, false); ctx.updater.forceDigest()`
889
+
890
+ The user's setup function runs again, but because it receives the same `ViewCtx` with preserved `updater.data`, any state set via `ctx.updater.set()` in the previous setup survives.
891
+
892
+ ### `forceDigest()` on Updater
893
+
894
+ Normal `digest()` only re-renders when data changed. After an HMR swap, the data is the same but the template changed. `forceDigest()` marks every current data key as changed before triggering the digest, forcing a full re-render.
895
+
896
+ ### Cross-bundler HMR API differences
897
+
898
+ | Bundler | HMR context | Accept callback receives new module |
899
+ | ------- | ----------------- | ----------------------------------- |
900
+ | Vite | `import.meta.hot` | Yes, via `newModule.default` |
901
+ | Webpack | `module.hot` | No, module already re-executed |
902
+ | Rspack | `module.hot` | No, module already re-executed |
903
+
904
+ In Vite, the accept callback runs in the OLD module's scope (local vars = old values). In Webpack/Rspack, it runs in the NEW module's scope (local vars = new values). Both snippets use `dispose` to `hot.data` to `accept` to pass the old reference across the cycle.
905
+
906
+ ### Architecture: why `hmr-inject.ts` is separate from `hmr.ts`
907
+
908
+ The injection code generator (`hmr-inject.ts`) has ZERO runtime imports — it only generates snippet strings. The runtime HMR functions (`hmr.ts`) import `./frame` which imports `./event-delegator` which accesses `document`. The three bundler plugin entry points (`vite.ts`, `webpack.ts`, `rspack.ts`) are loaded in Node.js (to process build config), so they import from `hmr-inject.ts`, NOT `hmr.ts`. This prevents `ReferenceError: document is not defined` in Node.js.
909
+
910
+ ### The `__larkTemplate` naming contract
911
+
912
+ `compileTemplate` emits `function __larkTemplate(data, viewId, refData) { ... }` + `export default __larkTemplate;` (named function, not anonymous) so the HMR snippet can reference it by name in the `dispose` callback. See `naming-convention.md` in the package root for the full list of cross-layer naming contracts.
913
+
914
+ ### Manual HMR API (fallback)
915
+
916
+ For cases where auto-injection does not cover your needs (e.g., a view file that does not import `.html`), use the manual API:
917
+
918
+ ```ts
919
+ import { defineView, acceptView, disposeView } from "@lark.js/mvc";
920
+ import type { HotContext } from "@lark.js/mvc";
921
+ import template from "./home.html";
922
+
923
+ const HomeView = defineView((ctx) => ({ template /* ... */ }));
924
+
925
+ if (import.meta.hot) {
926
+ const hot = import.meta.hot as HotContext;
927
+ disposeView(hot, "home");
928
+ acceptView(hot, "home");
929
+ }
930
+
931
+ export default HomeView;
932
+ ```
933
+
934
+ `acceptView(hot, viewPath)` calls `hotSwapFrames(viewPath, newSetup)` (state-preserving). `disposeView(hot, viewPath)` calls `invalidateViewClass(viewPath)`. Both are no-ops when `hot` is `undefined`.
935
+
936
+ ### What is NOT preserved across HMR
937
+
938
+ - **`destroyOnRender` resources**: destroyed on every render including HMR force-render (by design)
939
+ - **Setup side effects**: since the setup function re-runs, any one-time setup logic will re-execute. If your setup sets initial data unconditionally, it will overwrite HMR-preserved data. Guard with `if (!ctx.rendered.value)` if needed.
940
+
941
+ ## Debugging and Devtool Bridge
850
942
 
851
943
  ### Frame Devtool Bridge
852
944
 
@@ -867,41 +959,53 @@ The `lark-devtool` sub-project in this repository is the paired Devtool that loa
867
959
  - `Framework.setConfig(patch)` — Merge configuration, returns merged result.
868
960
  - `Framework.use(names, callback?)` — Async view loader; returns `Promise<unknown[]>` when no callback.
869
961
  - `Framework.mark(host, key)` / `Framework.unmark(host)` — Async callback validity tracking via module-level `WeakMap`.
870
- - `Framework.dispatch(target, type, init?)` — Trigger custom DOM event.
962
+ - `Framework.dispatchEvent(target, type, init?)` — Trigger custom DOM event.
871
963
  - `Framework.task(fn, args?, ctx?)` — Chunked execution: prefers `scheduler.postTask` then `requestIdleCallback` then `setTimeout(0)`, with a fixed 48ms budget or adaptive time slicing.
872
964
  - `Framework.delay(ms)` — Promise-wrapped setTimeout.
873
965
  - `Framework.waitZoneViewsRendered(viewId, timeout?)` — Wait until all views in a zone have rendered.
874
- - `Framework.applyStyle(idOrPairs, css?)` — Dynamically inject CSS, returns cleanup function.
875
966
 
876
967
  ### Updater
877
968
 
878
969
  - `updater.get(key?)` — Read data; returns entire data object when no key.
879
970
  - `updater.set(data, excludes?)` — Shallow merge and collect changed keys.
880
971
  - `updater.digest(data?, excludes?, callback?)` — Render; supports re-entry via `digestingQueue`.
972
+ - `updater.forceDigest()` — Force full re-render bypassing change detection. Marks all data keys as changed. Used by HMR to apply a new template against preserved data.
881
973
  - `updater.snapshot()` — Record current monotonic version.
882
974
  - `updater.altered()` — Check if changed, returns `boolean | undefined`.
883
975
  - `updater.translate(value)` — Resolve SPLITTER + number reference tokens to original values.
884
976
  - `updater.parse(expr)` — Safe path parser: dot paths (`a.b.c`) or numeric literals only, no eval.
885
977
  - `updater.getChangedKeys()` — `ReadonlySet<string>` of keys changed since last digest.
886
978
 
979
+ ### HMR
980
+
981
+ - `hotSwapByTemplate(oldTemplate, newTemplate)` — Template-only HMR: find views by template reference, replace, force-render.
982
+ - `hotSwapByView(oldSetup, newSetup)` — View setup HMR: update registry + hot-swap all matching frames.
983
+ - `hotSwapView(frame, newSetup)` — Single-frame in-place setup re-run (building block).
984
+ - `hotSwapFrames(viewPath, newSetup)` — Batch `hotSwapView` by viewPath.
985
+ - `reloadViews(viewPath)` — Legacy full-remount (destroys ctx, loses state). Prefer `hotSwapFrames`.
986
+ - `acceptView(hot, viewPath)` / `disposeView(hot, viewPath)` — Manual HMR API (fallback).
987
+ - `injectTemplateHmrSnippet(source, bundler)` / `injectViewHmr(source, bundler)` — Snippet generators for plugin internals.
988
+ - `forceDigest()` — On `Updater`, forces full re-render (see Updater section above).
989
+ - `HotContext` interface, `Bundler` type (`"vite" | "webpack" | "rspack"`).
990
+
887
991
  ### Store (zustand-style)
888
992
 
889
- - `create(name, (set, get) => body)` — Create store, returns `StoreApi`.
993
+ - `createStore(name, (set, get) => body)` — Create store, returns `StoreApi`.
890
994
  - `store.getState()` — Read current state.
891
995
  - `store.setState(partial | updater)` — Shallow merge, notify all listeners.
892
996
  - `store.subscribe(listener)` — Listen for changes, returns unsubscribe function.
893
997
  - `store.destroy()` — Destroy store, clear listeners.
894
998
  - `computed(deps, fn)` — Declare derived state.
895
999
  - `bindStore(view, store, selector?)` — Bind to Lark View with auto-sync and auto-cleanup.
896
- - `useUrlState(view, initialState?)` — URL parameter state sync.
1000
+ - `useUrlState(ctx, initialState?)` — URL parameter state sync.
897
1001
 
898
1002
  ## Common Pitfalls
899
1003
 
900
1004
  1. `boot.ts` must be inside `src/`: HTML references `/src/boot.ts`; placing it at the project root causes runtime resolution failure.
901
- 2. `registerViewClass` must precede `Framework.boot()`: all View classes (including sub-components) must either be pre-registered or loaded via `FrameworkConfig.require`.
1005
+ 2. `registerViewClass` must precede `Framework.boot()`: all view setups (including sub-components) must either be pre-registered or loaded via `FrameworkConfig.require`.
902
1006
  3. `.html` imports require build integration: only works in projects compiled by `larkMvcPlugin` / `larkMvcLoader`.
903
1007
  4. Write State with `State.set` + `State.digest`, never mutate the returned object directly: Safeguard warns in debug mode, deduplicated by key.
904
- 5. `bindStore` auto-unsubscribes on view destroy; manual `store.subscribe(listener)` calls need explicit cleanup (e.g., `this.on("destroy", off)`).
1008
+ 5. `bindStore` auto-unsubscribes on view destroy; manual `store.subscribe(listener)` calls need explicit cleanup (e.g., `ctx.on("destroy", off)`).
905
1009
  6. Event methods use `<>` not `()`: write `name<click>`, not `name(click)`.
906
1010
  7. `assign()` must have `snapshot` at the top and `return altered()` at the bottom: both are required for the framework to determine re-render necessity.
907
1011
  8. Never modify `view.signature`: internally managed; 0 means destroyed, render wrapper auto-increments.
@@ -911,12 +1015,17 @@ The `lark-devtool` sub-project in this repository is the paired Devtool that loa
911
1015
  12. Store state is a plain object: `store.getState()` returns the actual state object; reads are direct access, writes must go through `setState()` or actions.
912
1016
  13. `forOf` requires `as`: `{{forOf list item}}` is a compile error.
913
1017
  14. `wrapAsync` validates by signature: callback only executes when `view.signature` matches the value at wrap time.
914
- 15. Frame object pool caps at `MAX_FRAME_POOL = 64`: do not retain Frame references after `unmountFrame`.
1018
+ 15. Do not retain Frame references after `unmountFrame`: destroyed frames are removed from the registry; stale references may point to reused or invalid objects.
915
1019
  16. Updater supports digest re-entry: digest during digest enters `digestingQueue`; `null` is the boundary.
916
1020
  17. Store creator runs once: state persists across view mount/unmount cycles; call `store.destroy()` to tear down.
917
- 18. State is simple, Store is complex: lightweight shared values use State; use `create()` for actions, derived data, or fine-grained subscriptions; always pair State writes with `mixins: [State.clean("keys")]` to prevent leaks.
1021
+ 18. State is simple, Store is complex: lightweight shared values use State; use `createStore()` for actions, derived data, or fine-grained subscriptions; always pair State writes with `State.clean("keys")` to prevent leaks.
918
1022
  19. MF view paths use the remote project name as prefix: `v-lark="remote-app/views/home"` triggers async loading via `FrameworkConfig.require` when unregistered; `@lark.js/mvc` must be `singleton: true`.
919
1023
  20. `splitChunks.chunks` must be `"async"` in MF projects: `"all"` breaks shared scope initialization.
1024
+ 21. HMR is zero-config: do not manually add `import.meta.hot` calls to view files. The plugin auto-injects HMR snippets. Manual `acceptView`/`disposeView` is only for files not covered by auto-injection (e.g., views that don't import `.html`).
1025
+ 22. HMR preserves `updater.data` but not setup side effects: since the setup function re-runs during HMR, any unconditional `ctx.updater.set()` calls will overwrite preserved data. Guard with `if (!ctx.rendered.value)` if needed.
1026
+ 23. `reloadViews` loses state, `hotSwapFrames` preserves it: `reloadViews` does a full unmount+remount (destroys the view context). `hotSwapFrames` does an in-place setup re-run. `acceptView` uses `hotSwapFrames`. Prefer `hotSwapFrames` / `hotSwapByView` / `hotSwapByTemplate` over `reloadViews`.
1027
+ 24. `hmr-inject.ts` must stay separate from `hmr.ts`: the injection functions are pure string generators with zero runtime imports. `hmr.ts` imports `./frame` which accesses `document`. The plugin entry points (`vite.ts`, `webpack.ts`, `rspack.ts`) load in Node.js and must not transitively import `hmr.ts`. Merging them causes `ReferenceError: document is not defined`.
1028
+ 25. `compileTemplate` emits `function __larkTemplate(...)`: the named function (not anonymous `export default function`) is a compile-time contract so the HMR snippet can reference it by name. See `naming-convention.md` for the full list of cross-layer naming contracts.
920
1029
 
921
1030
  ## Comparison with Vue 3 / React 19
922
1031
 
@@ -926,7 +1035,7 @@ Similarities: templates compile to functions; reactivity via Proxy; derived data
926
1035
 
927
1036
  | Dimension | Vue 3 | Lark |
928
1037
  | --------------------- | -------------------------------------- | ----------------------------------------------------- |
929
- | Component abstraction | SFC / function components / Options | Class inheritance `View.extend` / `defineView` |
1038
+ | Component abstraction | SFC / function components / Options | Functional `defineView(setup)` + `ViewCtx` + Hooks |
930
1039
  | Render output | VNode patch | HTML string parsed to real DOM + diff |
931
1040
  | Template syntax | `v-if` / `v-for` / `:bind` | `{{if}}` / `{{forOf}}` / `@event` / `v-lark` |
932
1041
  | Dependency tracking | Automatic effect tracking | subscribe + bindStore + computed |
@@ -942,9 +1051,9 @@ Similarities: unidirectional data flow; immutable write-back style; async protec
942
1051
 
943
1052
  | Dimension | React 19 | Lark |
944
1053
  | --------------------- | ---------------------------------------- | ------------------------------------------------------------ |
945
- | Component abstraction | Function components + Hooks | Class inheritance `View.extend` / `defineView` |
946
- | State encapsulation | `useState` / `useReducer` | View instance fields, `create()` store, `State` |
947
- | Side effects | `useEffect` / `useLayoutEffect` | `init` / `make` + `capture` / `release` |
1054
+ | Component abstraction | Function components + Hooks | Functional `defineView(setup)` + `ViewCtx` + Hooks |
1055
+ | State encapsulation | `useState` / `useReducer` | View ctx fields, `createStore()` store, `State` |
1056
+ | Side effects | `useEffect` / `useLayoutEffect` | `useEffect` hook + `ctx.capture` / `ctx.release` |
948
1057
  | Render interruption | Fiber time-slicing, Suspense, Transition | Synchronous digest, not interruptible |
949
1058
  | Compile optimization | React Compiler (auto-memo) | Template compile-time only; no runtime auto-memo |
950
1059
  | Server rendering | RSC, streaming SSR | Not supported (design trade-off) |
@@ -970,4 +1079,4 @@ pnpm format # prettier formatting
970
1079
 
971
1080
  ## License
972
1081
 
973
- ISC. See `LICENSE` in the repository root.
1082
+ MIT. See `LICENSE` in the repository root.