@preact-mvc/core 0.1.5 → 0.1.7

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/dist/index.d.ts CHANGED
@@ -4,7 +4,7 @@ export { Injector, InjectionToken, inject, runInInjectionContext, getCurrentInje
4
4
  export type { Provider, Token, Type } from './injector';
5
5
  export { Injectable, Controller, Module, Component } from './decorators';
6
6
  export type { InjectableMeta, ModuleMeta, ComponentMeta } from './decorators';
7
- export { View, installViewInterop } from './view';
7
+ export { View, installViewInterop, errorHint } from './view';
8
8
  export { bootstrap, createModuleInjector, getModuleInjector } from './module';
9
9
  export { pmvc } from './pmvc';
10
10
  export { InjectorContext, useInjector, useService, Provide } from './context';
package/dist/index.js CHANGED
@@ -5,7 +5,7 @@ export { Injector, InjectionToken, inject, runInInjectionContext, getCurrentInje
5
5
  // Metadata
6
6
  export { Injectable, Controller, Module, Component } from './decorators';
7
7
  // Class-based views
8
- export { View, installViewInterop } from './view';
8
+ export { View, installViewInterop, errorHint } from './view';
9
9
  // Modules & bootstrap
10
10
  export { bootstrap, createModuleInjector, getModuleInjector } from './module';
11
11
  // Class-flag utilities — pmvc.isView(x), pmvc.isController(x), ...
package/dist/module.js CHANGED
@@ -4,8 +4,11 @@ import { InjectorContext } from './context';
4
4
  import { installViewInterop } from './view';
5
5
  import { ownMeta } from './decorators';
6
6
  // Modules are the unit of provider scoping. A module's providers (plus those
7
- // of its imports, transitively) register into a single child Injector. The
8
- // injector tree mirrors the view tree: root injector -> AppModule injector ->
7
+ // of its imports, transitively) register into a single Injector. The
8
+ // bootstrap module registers into the ROOT injector itself — matching
9
+ // Angular, where eager module providers share the root scope — so
10
+ // providedIn: 'root' services can inject them. Only feature/lazy modules
11
+ // get child injectors: root injector (holding the app module's graph) ->
9
12
  // feature/lazy module injectors -> per-view injectors.
10
13
  export function getModuleMeta(mod) {
11
14
  return ownMeta(mod, '__module');
@@ -63,11 +66,14 @@ export function getModuleInjector(mod, parent) {
63
66
  export function bootstrap(RootModule, el) {
64
67
  installViewInterop();
65
68
  const root = new Injector(null, 'root');
66
- const appInjector = createModuleInjector(RootModule, root);
67
- const meta = getModuleMeta(RootModule);
68
- const App = meta.bootstrap?.[0];
69
+ // The bootstrapped module is eager: its providers and imports merge into
70
+ // the root injector, so providedIn: 'root' services (instantiated under
71
+ // root) can inject them — including forRoot() bundles like
72
+ // RouterModule.forRoot().
73
+ registerModule(RootModule, root, new Set());
74
+ const App = getModuleMeta(RootModule).bootstrap?.[0];
69
75
  if (!App) {
70
76
  throw new Error('Root module must declare a bootstrap component: @Module({ bootstrap: [AppView] })');
71
77
  }
72
- render(h(InjectorContext.Provider, { value: appInjector }, h(App, {})), el);
78
+ render(h(InjectorContext.Provider, { value: root }, h(App, {})), el);
73
79
  }
package/dist/view.d.ts CHANGED
@@ -8,4 +8,6 @@ export declare abstract class View<P = {}> extends PreactComponent<P> {
8
8
  }
9
9
  /** Identity stamp read — a subclass of a View stays a view. */
10
10
  export declare function isView(t: any): boolean;
11
+ /** Map a caught error to a known-cause hint, or null when unrecognized. */
12
+ export declare function errorHint(error: any): string | null;
11
13
  export declare function installViewInterop(): void;
package/dist/view.js CHANGED
@@ -35,6 +35,27 @@ export function isView(t) {
35
35
  // Real Preact class component (not a View — it must be instantiated by Preact).
36
36
  // Wraps every view host so mount/render failures stay local instead of
37
37
  // aborting the whole render pass.
38
+ // Opaque upstream error signatures -> actionable guidance, appended to the
39
+ // boundary output and the console.error. Add entries when a cryptic thrown
40
+ // error has a known real cause.
41
+ const ERROR_HINTS = [
42
+ [
43
+ /Hook can only be invoked from render methods/,
44
+ 'a hook ran outside a render pass. A common cause: a function component ' +
45
+ 'mounts a Preact sub-tree with render() inside useEffect. Pending ' +
46
+ "useEffects flush inside options.__r on a re-render, and the nested " +
47
+ "diff corrupts preact/debug's hook tracking for the outer component. " +
48
+ 'Mount sub-trees in useLayoutEffect instead.',
49
+ ],
50
+ ];
51
+ /** Map a caught error to a known-cause hint, or null when unrecognized. */
52
+ export function errorHint(error) {
53
+ const msg = String(error?.message ?? error);
54
+ for (const [re, hint] of ERROR_HINTS)
55
+ if (re.test(msg))
56
+ return hint;
57
+ return null;
58
+ }
38
59
  class ViewErrorBoundary extends PreactComponent {
39
60
  constructor() {
40
61
  super(...arguments);
@@ -42,12 +63,16 @@ class ViewErrorBoundary extends PreactComponent {
42
63
  }
43
64
  componentDidCatch(error) {
44
65
  console.error(`[framework] ${this.props.viewName} failed:`, error);
66
+ const hint = errorHint(error);
67
+ if (hint)
68
+ console.error(`[framework] hint for ${this.props.viewName}:`, hint);
45
69
  this.setState({ error });
46
70
  }
47
71
  render() {
48
72
  const err = this.state.error;
49
73
  if (!err)
50
74
  return this.props.children;
75
+ const hint = errorHint(err);
51
76
  return h('div', {
52
77
  style: {
53
78
  border: '1px solid #e53935',
@@ -57,7 +82,7 @@ class ViewErrorBoundary extends PreactComponent {
57
82
  fontFamily: 'monospace',
58
83
  fontSize: '0.85rem',
59
84
  },
60
- }, h('strong', null, `${this.props.viewName} failed to mount`), h('pre', { style: { whiteSpace: 'pre-wrap', margin: '0.5rem 0' } }, String(err?.message ?? err)), h('button', { onClick: () => this.setState({ error: null }) }, 'Retry'));
85
+ }, h('strong', null, `${this.props.viewName} failed`), h('pre', { style: { whiteSpace: 'pre-wrap', margin: '0.5rem 0' } }, String(err?.message ?? err)), hint ? h('p', { style: { margin: '0 0 0.5rem' } }, `hint: ${hint}`) : null, h('button', { onClick: () => this.setState({ error: null }) }, 'Retry'));
61
86
  }
62
87
  }
63
88
  // --- Host -------------------------------------------------------------------
@@ -120,6 +145,42 @@ function hostFor(cls) {
120
145
  hostCache.set(cls, host);
121
146
  return host;
122
147
  }
148
+ // --- preact/debug resilience --------------------------------------------------
149
+ // preact/hooks flushes a component's pending useEffects inside options.__r
150
+ // when it re-renders before the async afterPaint pass. A pending effect that
151
+ // calls render() runs a nested diff whose `diffed` clears preact/debug's
152
+ // hooksAllowed flag — and the flag stays cleared when the component's own
153
+ // render body resumes, so its next hook throws "Hook can only be invoked
154
+ // from render methods" falsely. The flag itself is module-local and cannot
155
+ // be repaired, so instead the guard's question is answered precisely:
156
+ // function component types are wrapped so `currentRender` is the instance
157
+ // executing its body, and the options.__h/_hook check tolerates a corrupt
158
+ // flag only when the hook's component provably IS that instance.
159
+ let currentRender = null;
160
+ const wrappedTypes = new WeakMap();
161
+ function isFunctionComponentType(t) {
162
+ return typeof t === 'function' && !(t.prototype && t.prototype.render);
163
+ }
164
+ function wrapFunctionComponent(type) {
165
+ let wrapped = wrappedTypes.get(type);
166
+ if (!wrapped) {
167
+ wrapped = function (props, ctx) {
168
+ const prev = currentRender;
169
+ currentRender = this;
170
+ try {
171
+ return type.call(this, props, ctx);
172
+ }
173
+ finally {
174
+ currentRender = prev;
175
+ }
176
+ };
177
+ // Statics (displayName, metadata stamps) resolve off the original type.
178
+ Object.setPrototypeOf(wrapped, type);
179
+ wrapped.displayName = type.displayName || type.name;
180
+ wrappedTypes.set(type, wrapped);
181
+ }
182
+ return wrapped;
183
+ }
123
184
  let installed = false;
124
185
  // Install the vnode interceptor. Must run before any view class is rendered;
125
186
  // called on module load and again defensively from bootstrap().
@@ -127,11 +188,35 @@ export function installViewInterop() {
127
188
  if (installed)
128
189
  return;
129
190
  installed = true;
191
+ // preact/debug installs its hook guard on options.__h (dist build) or
192
+ // options._hook (source build). Component-type wrapping only engages when
193
+ // a guard exists to protect: production has no check that can corrupt.
194
+ // The guard must already be installed, so `import 'preact/debug'` has to
195
+ // precede framework imports (preset-vite injects it first).
196
+ const guardKeys = ['__h', '_hook'].filter((k) => typeof options[k] === 'function');
197
+ const debugLoaded = guardKeys.length > 0;
198
+ for (const key of guardKeys) {
199
+ const check = options[key];
200
+ options[key] = (comp, index, type) => {
201
+ try {
202
+ check(comp, index, type);
203
+ }
204
+ catch (err) {
205
+ if (!comp || comp !== currentRender)
206
+ throw err;
207
+ // comp is executing its own render body: the call is legal, the
208
+ // guard's flag was cleared by a nested diffed inside options.__r.
209
+ }
210
+ };
211
+ }
130
212
  const prev = options.vnode;
131
213
  options.vnode = (vnode) => {
132
214
  const t = vnode.type;
133
215
  if (isView(t))
134
216
  vnode.type = hostFor(t);
217
+ else if (debugLoaded && isFunctionComponentType(t)) {
218
+ vnode.type = wrapFunctionComponent(t);
219
+ }
135
220
  prev?.(vnode);
136
221
  };
137
222
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@preact-mvc/core",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "Angular-inspired modules/DI/decorators + class-based JSX views rendered by Preact + signal reactivity",
5
5
  "type": "module",
6
6
  "sideEffects": false,