@preact-mvc/core 0.1.2 → 0.1.4

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.
@@ -1,4 +1,14 @@
1
1
  import type { Provider, Type } from './injector';
2
+ /** A class's own decorator metadata — never the inherited static chain. */
3
+ export declare function ownMeta<T>(target: any, key: string): T | undefined;
4
+ /** Identity stamp: a subclass of a @Controller stays a controller. */
5
+ export declare function isController(t: any): boolean;
6
+ /** Injectable metadata — reads the chain on purpose (providedIn inherits). */
7
+ export declare function getInjectableMeta(t: any): InjectableMeta | undefined;
8
+ /** Identity read: the class carries @Injectable metadata. */
9
+ export declare function isInjectable(t: any): boolean;
10
+ /** Component metadata — own-class only; a subclass re-declares its own. */
11
+ export declare function getComponentMeta(t: any): ComponentMeta | undefined;
2
12
  export interface InjectableMeta {
3
13
  providedIn?: 'root';
4
14
  }
@@ -1,3 +1,41 @@
1
+ // Decorators attach metadata to the class constructor. They work with
2
+ // experimentalDecorators (esbuild honors the tsconfig flag) and can also be
3
+ // invoked manually: `Module({ ... })(MyModule)`.
4
+ //
5
+ // Two stamp families:
6
+ // identity (__view, __controller) — booleans answering "is this class a
7
+ // kind of thing"; they inherit through the constructor chain, so a
8
+ // subclass keeps the kind.
9
+ // config (__module, __component, __injectable) — metadata describing one
10
+ // class; readers use ownMeta so a subclass never borrows its parent's
11
+ // config (Angular's rule: @NgModule/@Component metadata does not
12
+ // inherit). __injectable is the exception at read time: the injector
13
+ // resolves providedIn through the chain, matching Angular's ɵprov.
14
+ /** A class's own decorator metadata — never the inherited static chain. */
15
+ export function ownMeta(target, key) {
16
+ return target != null && Object.prototype.hasOwnProperty.call(target, key)
17
+ ? target[key]
18
+ : undefined;
19
+ }
20
+ // --- Flag accessors -----------------------------------------------------------
21
+ // The read surface for the stamps below. Callers go through these instead of
22
+ // touching `__x` properties, so the own-vs-inherited rules live in one place.
23
+ /** Identity stamp: a subclass of a @Controller stays a controller. */
24
+ export function isController(t) {
25
+ return typeof t === 'function' && !!t.__controller;
26
+ }
27
+ /** Injectable metadata — reads the chain on purpose (providedIn inherits). */
28
+ export function getInjectableMeta(t) {
29
+ return t?.__injectable;
30
+ }
31
+ /** Identity read: the class carries @Injectable metadata. */
32
+ export function isInjectable(t) {
33
+ return getInjectableMeta(t) != null;
34
+ }
35
+ /** Component metadata — own-class only; a subclass re-declares its own. */
36
+ export function getComponentMeta(t) {
37
+ return ownMeta(t, '__component');
38
+ }
1
39
  export function Injectable(meta = {}) {
2
40
  return function (target) {
3
41
  target.__injectable = meta;
package/dist/index.d.ts CHANGED
@@ -5,9 +5,12 @@ 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
7
  export { View, installViewInterop } from './view';
8
- export { bootstrap, createModuleInjector, getModuleInjector, getModuleMeta } from './module';
8
+ export { bootstrap, createModuleInjector, getModuleInjector } from './module';
9
+ export { pmvc } from './pmvc';
9
10
  export { InjectorContext, useInjector, useService, Provide } from './context';
10
11
  export { useController } from './controller';
11
12
  export type { OnInit, OnDestroy } from './controller';
13
+ export { lazyView, lazyModule } from './lazy';
14
+ export type { LazyResource, LazyResourceState } from './lazy';
12
15
  export { Router, ROUTES, ROUTER_BASE, RouterModule, RouterOutlet, Link, defaultBasePath } from './router';
13
16
  export type { Route, RouteMatch, RouterOptions } from './router';
package/dist/index.js CHANGED
@@ -7,9 +7,13 @@ export { Injectable, Controller, Module, Component } from './decorators';
7
7
  // Class-based views
8
8
  export { View, installViewInterop } from './view';
9
9
  // Modules & bootstrap
10
- export { bootstrap, createModuleInjector, getModuleInjector, getModuleMeta } from './module';
10
+ export { bootstrap, createModuleInjector, getModuleInjector } from './module';
11
+ // Class-flag utilities — pmvc.isView(x), pmvc.isController(x), ...
12
+ export { pmvc } from './pmvc';
11
13
  // View integration
12
14
  export { InjectorContext, useInjector, useService, Provide } from './context';
13
15
  export { useController } from './controller';
16
+ // Lazy loading — usable in route configs and straight in JSX
17
+ export { lazyView, lazyModule } from './lazy';
14
18
  // Router
15
19
  export { Router, ROUTES, ROUTER_BASE, RouterModule, RouterOutlet, Link, defaultBasePath } from './router';
package/dist/injector.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { isController, getInjectableMeta } from './decorators';
1
2
  export class InjectionToken {
2
3
  constructor(_desc) {
3
4
  this._desc = _desc;
@@ -116,7 +117,7 @@ export class Injector {
116
117
  has(token) {
117
118
  if (this.records.has(token))
118
119
  return true;
119
- if (token.__controller)
120
+ if (isController(token))
120
121
  return true;
121
122
  if (this.parent)
122
123
  return this.parent.has(token);
@@ -135,7 +136,7 @@ export class Injector {
135
136
  }
136
137
  // Controllers are transient unless explicitly provided: one instance per
137
138
  // requesting scope, so a view always gets its own controller.
138
- if (token.__controller) {
139
+ if (isController(token)) {
139
140
  this.provide(token);
140
141
  return this.resolveRecord(token, this.records.get(token));
141
142
  }
@@ -165,8 +166,7 @@ export class Injector {
165
166
  return rec.value;
166
167
  }
167
168
  isRootProvided(token) {
168
- const meta = token.__injectable;
169
- return meta?.providedIn === 'root';
169
+ return getInjectableMeta(token)?.providedIn === 'root';
170
170
  }
171
171
  instantiate(cls, ...args) {
172
172
  const inst = runInInjectionContext(this, () => new cls(...args));
package/dist/lazy.d.ts ADDED
@@ -0,0 +1,50 @@
1
+ import type { FunctionComponent } from 'preact';
2
+ export type LazyResourceState<R> = {
3
+ status: 'loading';
4
+ } | {
5
+ status: 'ready';
6
+ value: R;
7
+ } | {
8
+ status: 'error';
9
+ error: unknown;
10
+ };
11
+ /**
12
+ * A deferred loader. `.state` reads kick the import on first access and
13
+ * subscribe the reader (a component render or a computed); `.ready` is the
14
+ * same resolution as a promise.
15
+ */
16
+ export interface LazyResource<R> {
17
+ readonly state: LazyResourceState<R>;
18
+ readonly ready: Promise<R>;
19
+ }
20
+ export declare function isComponent(v: any): boolean;
21
+ export declare function pickComponent(ns: any, label: string): any;
22
+ export declare function pickLazyFeature(ns: any, label: string): any;
23
+ export declare function pickModule(ns: any, label: string): any;
24
+ /**
25
+ * `lazyView(() => import('./hello.view'))` — a lazy standalone component.
26
+ * Usable as a Route `component` or directly in JSX: the facade suspends on
27
+ * the import, then mounts the resolved view with the same props.
28
+ */
29
+ export declare function lazyView(load: () => Promise<any>): FunctionComponent<any>;
30
+ /**
31
+ * `lazyModule(() => import('./admin.module'))` — a lazy feature module.
32
+ * Usable as a Route `module` (the outlet resolves the real module and its
33
+ * injector) or `component`, or directly in JSX: mounts the module's
34
+ * bootstrap view inside the module injector once the chunk lands.
35
+ */
36
+ export declare function lazyModule(load: () => Promise<any>): FunctionComponent<any>;
37
+ /**
38
+ * Read a lazyView/lazyModule facade's resource state — kicks the loader on
39
+ * first read and subscribes the reader (outlet render, router.pending
40
+ * computed). Non-facade values answer null.
41
+ */
42
+ export declare function lazyStateOf(v: any): LazyResourceState<any> | null;
43
+ /**
44
+ * Sync unwrap for the Route `module` field: a lazyModule facade answers
45
+ * with the resolved module class once loaded (undefined while pending or
46
+ * after a failure). Reading the resource state subscribes the caller.
47
+ */
48
+ export declare function moduleRefOf(m: any): any;
49
+ /** Async unwrap — awaits the ref's own resolution (kicking it if needed). */
50
+ export declare function lazyModuleReady(m: any): Promise<any> | null;
package/dist/lazy.js ADDED
@@ -0,0 +1,148 @@
1
+ import { Fragment as _Fragment, jsx as _jsx } from "preact/jsx-runtime";
2
+ import { h } from 'preact';
3
+ import { signal } from '@preact/signals';
4
+ import { InjectorContext, useInjector } from './context';
5
+ import { getModuleInjector, getModuleMeta } from './module';
6
+ import { isView } from './view';
7
+ function lazyResource(run, label) {
8
+ let s;
9
+ let promise;
10
+ const start = () => {
11
+ if (!s) {
12
+ s = signal({ status: 'loading' });
13
+ promise = run().then((value) => {
14
+ s.value = { status: 'ready', value };
15
+ return value;
16
+ }, (error) => {
17
+ console.error(`[framework] ${label} failed to load:`, error);
18
+ s.value = { status: 'error', error };
19
+ throw error;
20
+ });
21
+ // The state object carries failures to renderers; keep the promise
22
+ // from also reporting an unhandled rejection.
23
+ promise.catch(() => { });
24
+ }
25
+ return { s: s, promise: promise };
26
+ };
27
+ return {
28
+ get state() {
29
+ return start().s.value;
30
+ },
31
+ get ready() {
32
+ return start().promise;
33
+ },
34
+ };
35
+ }
36
+ // --- Export pickers ---------------------------------------------------------
37
+ // A lazy import resolves to: the ES module namespace ({ default: X, Y }),
38
+ // a value picked by .then(m => m.X), or a bare Route[]. Candidates are
39
+ // ordered default-first so `export default` always wins.
40
+ function exportCandidates(ns) {
41
+ if (Array.isArray(ns) || typeof ns !== 'object' || ns === null)
42
+ return [ns];
43
+ return 'default' in ns ? [ns.default, ...Object.values(ns)] : Object.values(ns);
44
+ }
45
+ export function isComponent(v) {
46
+ // Detection is an is-a question, so these read the chain: a subclass of a
47
+ // stamped component is still renderable (config reads stay own-only).
48
+ return (typeof v === 'function' &&
49
+ (isView(v) || !!v.__component || !!v.__lazyView));
50
+ }
51
+ export function pickComponent(ns, label) {
52
+ for (const c of exportCandidates(ns)) {
53
+ if (isComponent(c))
54
+ return c;
55
+ }
56
+ // Unstamped function components resolve only where unambiguous: the
57
+ // loader's direct return value (.then(m => m.X)) or a default export.
58
+ if (typeof ns === 'function')
59
+ return ns;
60
+ if (typeof ns === 'object' && ns !== null && typeof ns.default === 'function') {
61
+ return ns.default;
62
+ }
63
+ throw new Error(`${label} resolved no view — expected a View class, @Component class, ` +
64
+ `or function component (default export or .then(m => m.X))`);
65
+ }
66
+ export function pickLazyFeature(ns, label) {
67
+ for (const c of exportCandidates(ns)) {
68
+ if (getModuleMeta(c) || Array.isArray(c))
69
+ return c;
70
+ }
71
+ throw new Error(`${label} resolved no feature — expected the module to export an ` +
72
+ `@Module class or a Route[] (default or named export)`);
73
+ }
74
+ export function pickModule(ns, label) {
75
+ const v = pickLazyFeature(ns, label);
76
+ if (Array.isArray(v)) {
77
+ throw new Error(`${label} resolved a Route[] — expected an @Module class`);
78
+ }
79
+ return v;
80
+ }
81
+ // --- Facades ----------------------------------------------------------------
82
+ /**
83
+ * `lazyView(() => import('./hello.view'))` — a lazy standalone component.
84
+ * Usable as a Route `component` or directly in JSX: the facade suspends on
85
+ * the import, then mounts the resolved view with the same props.
86
+ */
87
+ export function lazyView(load) {
88
+ const res = lazyResource(() => load().then((ns) => pickComponent(ns, 'lazyView')), 'lazyView');
89
+ const LazyView = ({ fallback, ...props }) => {
90
+ const st = res.state;
91
+ if (st.status === 'ready')
92
+ return h(st.value, props);
93
+ return st.status === 'loading' ? _jsx(_Fragment, { children: fallback ?? null }) : null;
94
+ };
95
+ LazyView.__lazyView = res;
96
+ LazyView.displayName = 'LazyView';
97
+ return LazyView;
98
+ }
99
+ /**
100
+ * `lazyModule(() => import('./admin.module'))` — a lazy feature module.
101
+ * Usable as a Route `module` (the outlet resolves the real module and its
102
+ * injector) or `component`, or directly in JSX: mounts the module's
103
+ * bootstrap view inside the module injector once the chunk lands.
104
+ */
105
+ export function lazyModule(load) {
106
+ const res = lazyResource(() => load().then((ns) => pickModule(ns, 'lazyModule')), 'lazyModule');
107
+ const LazyModule = ({ fallback, ...props }) => {
108
+ const parent = useInjector();
109
+ const st = res.state;
110
+ const mod = st.status === 'ready' ? st.value : undefined;
111
+ const Boot = mod ? getModuleMeta(mod)?.bootstrap?.[0] : null;
112
+ if (!Boot)
113
+ return st.status === 'loading' ? _jsx(_Fragment, { children: fallback ?? null }) : null;
114
+ const inj = getModuleInjector(mod, parent);
115
+ return h(InjectorContext.Provider, { value: inj }, h(Boot, props));
116
+ };
117
+ LazyModule.__lazyModule = res;
118
+ LazyModule.displayName = 'LazyModule';
119
+ return LazyModule;
120
+ }
121
+ // --- Route-field integration -------------------------------------------------
122
+ /**
123
+ * Read a lazyView/lazyModule facade's resource state — kicks the loader on
124
+ * first read and subscribes the reader (outlet render, router.pending
125
+ * computed). Non-facade values answer null.
126
+ */
127
+ export function lazyStateOf(v) {
128
+ const res = typeof v === 'function' ? v.__lazyView ?? v.__lazyModule : null;
129
+ return res ? res.state : null;
130
+ }
131
+ /**
132
+ * Sync unwrap for the Route `module` field: a lazyModule facade answers
133
+ * with the resolved module class once loaded (undefined while pending or
134
+ * after a failure). Reading the resource state subscribes the caller.
135
+ */
136
+ export function moduleRefOf(m) {
137
+ if (typeof m === 'function' && m.__lazyModule) {
138
+ const st = m.__lazyModule.state;
139
+ return st.status === 'ready' ? st.value : undefined;
140
+ }
141
+ return m;
142
+ }
143
+ /** Async unwrap — awaits the ref's own resolution (kicking it if needed). */
144
+ export function lazyModuleReady(m) {
145
+ return typeof m === 'function' && m.__lazyModule
146
+ ? m.__lazyModule.ready
147
+ : null;
148
+ }
package/dist/module.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Injector } from './injector';
2
2
  import type { ModuleMeta } from './decorators';
3
3
  export declare function getModuleMeta(mod: any): ModuleMeta | undefined;
4
+ export declare function isModule(mod: any): boolean;
4
5
  export declare function registerModule(mod: any, injector: Injector, seen: Set<any>): void;
5
6
  export declare function createModuleInjector(mod: any, parent: Injector): Injector;
6
7
  export declare function getModuleInjector(mod: any, parent: Injector): Injector;
package/dist/module.js CHANGED
@@ -2,12 +2,16 @@ import { h, render } from 'preact';
2
2
  import { Injector } from './injector';
3
3
  import { InjectorContext } from './context';
4
4
  import { installViewInterop } from './view';
5
+ import { ownMeta } from './decorators';
5
6
  // Modules are the unit of provider scoping. A module's providers (plus those
6
7
  // of its imports, transitively) register into a single child Injector. The
7
8
  // injector tree mirrors the view tree: root injector -> AppModule injector ->
8
9
  // feature/lazy module injectors -> per-view injectors.
9
10
  export function getModuleMeta(mod) {
10
- return mod?.__module;
11
+ return ownMeta(mod, '__module');
12
+ }
13
+ export function isModule(mod) {
14
+ return getModuleMeta(mod) != null;
11
15
  }
12
16
  // Applies a module to an injector: registers all transitively imported
13
17
  // modules (and their providers/controllers) into it and marks them visible
package/dist/pmvc.d.ts ADDED
@@ -0,0 +1,14 @@
1
+ import { isView } from './view';
2
+ import { isController, isInjectable, getComponentMeta, getInjectableMeta } from './decorators';
3
+ import { getModuleMeta, isModule } from './module';
4
+ import { isComponent } from './lazy';
5
+ export declare const pmvc: Readonly<{
6
+ isView: typeof isView;
7
+ isController: typeof isController;
8
+ isModule: typeof isModule;
9
+ isInjectable: typeof isInjectable;
10
+ isComponent: typeof isComponent;
11
+ getModuleMeta: typeof getModuleMeta;
12
+ getComponentMeta: typeof getComponentMeta;
13
+ getInjectableMeta: typeof getInjectableMeta;
14
+ }>;
package/dist/pmvc.js ADDED
@@ -0,0 +1,22 @@
1
+ import { isView } from './view';
2
+ import { isController, isInjectable, getComponentMeta, getInjectableMeta, } from './decorators';
3
+ import { getModuleMeta, isModule } from './module';
4
+ import { isComponent } from './lazy';
5
+ // The framework's class-flag read surface in one place: `pmvc.isView(x)`,
6
+ // `pmvc.isController(x)`. Classes are identified by `__x` stamps (see
7
+ // decorators.ts for the stamp families); these accessors encode the
8
+ // own-vs-inherited rules so callers never touch the raw fields:
9
+ //
10
+ // identity, inherited: isView, isController, isInjectable, isComponent
11
+ // config, own-class: isModule, getModuleMeta, getComponentMeta
12
+ // config, inherited: getInjectableMeta (Angular's ɵprov rule)
13
+ export const pmvc = Object.freeze({
14
+ isView,
15
+ isController,
16
+ isModule,
17
+ isInjectable,
18
+ isComponent,
19
+ getModuleMeta,
20
+ getComponentMeta,
21
+ getInjectableMeta,
22
+ });
@@ -0,0 +1,48 @@
1
+ import type { Signal } from '@preact/signals';
2
+ import type { Route } from './router';
3
+ export interface RouteResolution {
4
+ /** The view/component this route renders at its depth (null = componentless
5
+ * wrapper — the outlet passes through to the next depth). */
6
+ component: any;
7
+ /** Module whose injector scopes this level (its bootstrap view is the
8
+ * fallback component). */
9
+ module?: any;
10
+ /** Child routes — static `children`, or a Route[] resolved by loadChildren. */
11
+ children?: Route[];
12
+ }
13
+ export type LazyRouteState = {
14
+ status: 'loading';
15
+ } | {
16
+ status: 'ready';
17
+ resolved: RouteResolution;
18
+ } | {
19
+ status: 'error';
20
+ error: unknown;
21
+ };
22
+ export declare function isLazyRoute(route: Route): boolean;
23
+ /** Sync resolution — what the route's static fields already know. Used for
24
+ * eager routes and as the render answer while a lazy route's chunk loads
25
+ * (an eager shell mounts immediately; its lazy children extend the match).
26
+ * A `module: lazyModule(...)` field reads the facade's resource: pending
27
+ * resolves to no module yet, and the read subscribes the outlet so 'ready'
28
+ * swaps the real module in. */
29
+ export declare function resolveRouteSync(route: Route): RouteResolution;
30
+ /**
31
+ * True while the route's own render target is still resolving — powers
32
+ * `router.pending` and the outlet's `fallback`. Only matched routes are
33
+ * asked, so the reads merely subscribe work the outlet would kick anyway.
34
+ */
35
+ export declare function routePending(route: Route): boolean;
36
+ /**
37
+ * True while a matched route's `loadChildren` is holding back deeper
38
+ * matches — the pending match alone ends the chain, so an outlet sitting
39
+ * one depth past it is where the loaded child will appear. Mirrors the
40
+ * condition matching uses before consulting the resolution signal.
41
+ */
42
+ export declare function routeChildPending(route: Route): boolean;
43
+ /**
44
+ * Per-route resolution state. First call kicks the loaders; the signal flips
45
+ * to 'ready'/'error' when they settle — `router.matches` re-runs because
46
+ * matching reads it for `loadChildren`, and outlets re-render on read.
47
+ */
48
+ export declare function lazyRouteState(route: Route): Signal<LazyRouteState>;
@@ -0,0 +1,88 @@
1
+ import { signal } from '@preact/signals';
2
+ import { getModuleMeta } from '../module';
3
+ import { lazyModuleReady, lazyStateOf, moduleRefOf, pickComponent, pickLazyFeature, } from '../lazy';
4
+ export function isLazyRoute(route) {
5
+ return !!(route.loadComponent || route.loadChildren);
6
+ }
7
+ /** Sync resolution — what the route's static fields already know. Used for
8
+ * eager routes and as the render answer while a lazy route's chunk loads
9
+ * (an eager shell mounts immediately; its lazy children extend the match).
10
+ * A `module: lazyModule(...)` field reads the facade's resource: pending
11
+ * resolves to no module yet, and the read subscribes the outlet so 'ready'
12
+ * swaps the real module in. */
13
+ export function resolveRouteSync(route) {
14
+ const mod = moduleRefOf(route.module);
15
+ return {
16
+ component: route.component ?? getModuleMeta(mod)?.bootstrap?.[0] ?? null,
17
+ module: mod,
18
+ children: route.children,
19
+ };
20
+ }
21
+ /**
22
+ * True while the route's own render target is still resolving — powers
23
+ * `router.pending` and the outlet's `fallback`. Only matched routes are
24
+ * asked, so the reads merely subscribe work the outlet would kick anyway.
25
+ */
26
+ export function routePending(route) {
27
+ if (isLazyRoute(route) && lazyRouteState(route).value.status === 'loading') {
28
+ return true;
29
+ }
30
+ return (lazyStateOf(route.component)?.status === 'loading' ||
31
+ lazyStateOf(route.module)?.status === 'loading');
32
+ }
33
+ /**
34
+ * True while a matched route's `loadChildren` is holding back deeper
35
+ * matches — the pending match alone ends the chain, so an outlet sitting
36
+ * one depth past it is where the loaded child will appear. Mirrors the
37
+ * condition matching uses before consulting the resolution signal.
38
+ */
39
+ export function routeChildPending(route) {
40
+ return (!route.children?.length &&
41
+ !!route.loadChildren &&
42
+ lazyRouteState(route).value.status === 'loading');
43
+ }
44
+ const states = new WeakMap();
45
+ /**
46
+ * Per-route resolution state. First call kicks the loaders; the signal flips
47
+ * to 'ready'/'error' when they settle — `router.matches` re-runs because
48
+ * matching reads it for `loadChildren`, and outlets re-render on read.
49
+ */
50
+ export function lazyRouteState(route) {
51
+ let s = states.get(route);
52
+ if (!s) {
53
+ s = signal({ status: 'loading' });
54
+ states.set(route, s);
55
+ resolveLazyRoute(route).then((resolved) => {
56
+ s.value = { status: 'ready', resolved };
57
+ }, (error) => {
58
+ console.error(`[framework] lazy route '${route.path}' failed to load:`, error);
59
+ s.value = { status: 'error', error };
60
+ });
61
+ }
62
+ return s;
63
+ }
64
+ async function resolveLazyRoute(route) {
65
+ const lazyMod = lazyModuleReady(route.module);
66
+ const [feature, comp, awaitedMod] = await Promise.all([
67
+ route.loadChildren ? route.loadChildren() : undefined,
68
+ route.loadComponent ? route.loadComponent() : undefined,
69
+ lazyMod ?? undefined,
70
+ ]);
71
+ let mod = lazyMod ? awaitedMod : route.module;
72
+ let children = route.children;
73
+ if (route.loadChildren) {
74
+ const picked = pickLazyFeature(feature, `loadChildren for route '${route.path}'`);
75
+ if (Array.isArray(picked)) {
76
+ if (!children?.length)
77
+ children = picked;
78
+ }
79
+ else {
80
+ mod ?? (mod = picked);
81
+ }
82
+ }
83
+ const component = route.component ??
84
+ (route.loadComponent ? pickComponent(comp, `loadComponent for route '${route.path}'`) : undefined) ??
85
+ getModuleMeta(mod)?.bootstrap?.[0] ??
86
+ null;
87
+ return { component, module: mod, children };
88
+ }
@@ -1,5 +1,5 @@
1
- import { getModuleMeta } from '../module';
2
1
  import { ROUTES, ROUTER_BASE, Router, defaultBasePath } from './router';
2
+ import { resolveRouteSync } from './lazy';
3
3
  // RouterModule.forRoot(routes) returns a module-shaped object — anything with
4
4
  // __module metadata can sit in `imports`, so static config works the same as
5
5
  // a decorated module class.
@@ -23,10 +23,5 @@ export class RouterModule {
23
23
  // Resolve which view a matched route should render: an explicit component, or
24
24
  // the module's declared bootstrap component.
25
25
  export function routeComponent(match) {
26
- const { route } = match;
27
- if (route.component)
28
- return route.component;
29
- if (route.module)
30
- return getModuleMeta(route.module)?.bootstrap?.[0] ?? null;
31
- return null;
26
+ return resolveRouteSync(match.route).component;
32
27
  }
@@ -1 +1,4 @@
1
- export declare function RouterOutlet(): import("preact").JSX.Element | null;
1
+ import type { ComponentChildren } from 'preact';
2
+ export declare function RouterOutlet(props: {
3
+ fallback?: ComponentChildren;
4
+ }): import("preact").JSX.Element | null;
@@ -1,10 +1,11 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "preact/jsx-runtime";
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "preact/jsx-runtime";
2
2
  import { createContext } from 'preact';
3
3
  import { useContext } from 'preact/hooks';
4
4
  import PRRouter from 'preact-router';
5
5
  import { useInjector, useService, InjectorContext } from '../context';
6
6
  import { getModuleInjector } from '../module';
7
- import { routeComponent } from './module';
7
+ import { lazyStateOf } from '../lazy';
8
+ import { isLazyRoute, lazyRouteState, resolveRouteSync, routeChildPending, } from './lazy';
8
9
  import { Router } from './router';
9
10
  /**
10
11
  * Which depth of the matched chain this outlet renders — the root outlet
@@ -20,25 +21,47 @@ const OutletDepth = createContext(0);
20
21
  * The sink renders nothing; url/matches props die on the component.
21
22
  */
22
23
  const BridgeSink = () => null;
23
- export function RouterOutlet() {
24
+ export function RouterOutlet(props) {
24
25
  const router = useService(Router);
25
26
  const parent = useInjector();
26
27
  const depth = useContext(OutletDepth);
27
28
  const matches = router.matches.value;
28
29
  const match = matches?.[depth];
29
30
  if (!match) {
31
+ // One depth past the chain while the last match's lazy children load —
32
+ // this outlet is where the loaded child will appear, so the fallback
33
+ // marks the spot. (Only loadChildren can hold back deeper matches.)
34
+ const chainLen = matches?.length ?? 0;
35
+ const last = chainLen ? matches[chainLen - 1] : undefined;
36
+ if (last && depth === chainLen && routeChildPending(last.route)) {
37
+ return _jsx(_Fragment, { children: props.fallback ?? null });
38
+ }
30
39
  // The outermost outlet keeps the bridge mounted even with no match, so
31
40
  // route() interop survives 404s.
32
41
  return depth === 0 ? (_jsx(PRRouter, { onChange: (e) => router.syncUrl(e.url), children: _jsx(BridgeSink, { default: true }) })) : null;
33
42
  }
34
- const Component = routeComponent(match);
35
- if (!Component)
36
- return null;
43
+ // Lazy routes resolve through a per-route signal: while 'loading' the
44
+ // static fields still answer (an eager shell mounts while its lazy
45
+ // children load); 'ready' swaps in the resolved component/module.
46
+ const st = isLazyRoute(match.route) ? lazyRouteState(match.route).value : null;
47
+ const resolved = st?.status === 'ready' ? st.resolved : resolveRouteSync(match.route);
48
+ const Component = resolved.component;
37
49
  // A route with `module` gets the module's injector — its providers are
38
50
  // scoped to that feature. Nesting is automatic: a deeper outlet resolves
39
51
  // its module under this level's injector.
40
- const injector = match.route.module
41
- ? getModuleInjector(match.route.module, parent)
52
+ const injector = resolved.module
53
+ ? getModuleInjector(resolved.module, parent)
42
54
  : parent;
43
- return (_jsxs(OutletDepth.Provider, { value: depth + 1, children: [depth === 0 && (_jsx(PRRouter, { onChange: (e) => router.syncUrl(e.url), children: _jsx(BridgeSink, { default: true }) })), _jsx(InjectorContext.Provider, { value: injector, children: _jsx(Component, {}) })] }));
55
+ // This depth is pending while its own render target is unresolved:
56
+ // route-level loaders with nothing statically known yet, a lazyModule
57
+ // facade in the module field, or a lazyView/lazyModule facade suspending
58
+ // on mount. An eager shell under a pending loadChildren is NOT pending —
59
+ // it renders now and the deeper outlet's fallback covers the child.
60
+ const pending = (st?.status === 'loading' && !Component && !resolved.module) ||
61
+ lazyStateOf(Component)?.status === 'loading' ||
62
+ lazyStateOf(match.route.module)?.status === 'loading';
63
+ return (_jsxs(OutletDepth.Provider, { value: depth + 1, children: [depth === 0 && (_jsx(PRRouter, { onChange: (e) => router.syncUrl(e.url), children: _jsx(BridgeSink, { default: true }) })), _jsx(InjectorContext.Provider, { value: injector, children: pending ? (props.fallback ?? null) : Component ? (_jsx(Component, {})) : (
64
+ /* A componentless route is a pure wrapper — the chain continues
65
+ one depth down without needing a shell view. */
66
+ _jsx(RouterOutlet, { fallback: props.fallback })) })] }));
44
67
  }
@@ -3,6 +3,20 @@ export interface Route {
3
3
  path: string;
4
4
  component?: any;
5
5
  module?: any;
6
+ /**
7
+ * Lazy standalone component — a bundler split point. The loader resolves
8
+ * to a View class / @Component-stamped class / function component, picked
9
+ * from the module's default export or stamped named exports (or selected
10
+ * explicitly: `() => import('./x.view').then(m => m.XView)`).
11
+ */
12
+ loadComponent?: () => Promise<any>;
13
+ /**
14
+ * Lazy feature — resolves to an @Module class (its bootstrap view renders
15
+ * inside the module injector, like `module`) or a Route[] spliced in as
16
+ * this route's children. Matching reads the resolution signal, so the
17
+ * chain extends as soon as the chunk lands.
18
+ */
19
+ loadChildren?: () => Promise<any>;
6
20
  children?: Route[];
7
21
  }
8
22
  export interface RouteMatch {
@@ -32,6 +46,12 @@ export declare class Router {
32
46
  match: import("@preact/signals-core").ReadonlySignal<RouteMatch | null>;
33
47
  /** Params merged across the whole chain (inner levels win on collision). */
34
48
  params: import("@preact/signals-core").ReadonlySignal<any>;
49
+ /**
50
+ * True while any route in the matched chain is still resolving its lazy
51
+ * chunk (loadComponent/loadChildren/lazyView/lazyModule) — parents read
52
+ * it to render loading UI anywhere above the outlet.
53
+ */
54
+ pending: import("@preact/signals-core").ReadonlySignal<boolean>;
35
55
  constructor(routes: Route[], base?: string);
36
56
  /**
37
57
  * Static-host deep links: the site's 404 shim redirects
@@ -8,6 +8,7 @@ import { computed, signal } from '@preact/signals';
8
8
  import { route as prRoute } from 'preact-router';
9
9
  import { Injectable } from '../decorators';
10
10
  import { InjectionToken } from '../injector';
11
+ import { lazyRouteState, routePending } from './lazy';
11
12
  export const ROUTES = new InjectionToken('ROUTES');
12
13
  export const ROUTER_BASE = new InjectionToken('ROUTER_BASE');
13
14
  /**
@@ -56,8 +57,25 @@ function matchSegments(routes, segs) {
56
57
  const m = matchPrefix(route.path, segs);
57
58
  if (m === null)
58
59
  continue;
59
- if (route.children?.length) {
60
- const child = matchSegments(route.children, m.rest);
60
+ let children = route.children;
61
+ let pending = false;
62
+ if (!children?.length && route.loadChildren) {
63
+ // Reading the resolution signal inside the matches computed does two
64
+ // things: kicks the loader, and subscribes this computed so the chain
65
+ // re-matches the moment a lazy Route[] lands.
66
+ const st = lazyRouteState(route).value;
67
+ if (st.status === 'loading')
68
+ pending = true;
69
+ else if (st.status === 'ready')
70
+ children = st.resolved.children;
71
+ }
72
+ if (pending) {
73
+ // Lazy children unresolved — the parent claims the URL alone until the
74
+ // chunk lands and the computed re-matches into the loaded tree.
75
+ return [{ route, params: m.params }];
76
+ }
77
+ if (children?.length) {
78
+ const child = matchSegments(children, m.rest);
61
79
  if (child !== null)
62
80
  return [{ route, params: m.params }, ...child];
63
81
  if (m.rest.length === 0)
@@ -86,6 +104,12 @@ let Router = class Router {
86
104
  this.match = computed(() => this.matches.value?.[0] ?? null);
87
105
  /** Params merged across the whole chain (inner levels win on collision). */
88
106
  this.params = computed(() => Object.assign({}, ...(this.matches.value ?? []).map((m) => m.params)));
107
+ /**
108
+ * True while any route in the matched chain is still resolving its lazy
109
+ * chunk (loadComponent/loadChildren/lazyView/lazyModule) — parents read
110
+ * it to render loading UI anywhere above the outlet.
111
+ */
112
+ this.pending = computed(() => !!this.matches.value?.some((m) => routePending(m.route)));
89
113
  this.onPopState = () => {
90
114
  this.syncUrl(location.pathname + location.search);
91
115
  };
package/dist/view.d.ts CHANGED
@@ -1,8 +1,11 @@
1
1
  import { Component as PreactComponent } from 'preact';
2
2
  import type { ComponentChildren } from 'preact';
3
3
  export declare abstract class View<P = {}> extends PreactComponent<P> {
4
+ static readonly __view = true;
4
5
  abstract render(): ComponentChildren;
5
6
  setState(): never;
6
7
  forceUpdate(): never;
7
8
  }
9
+ /** Identity stamp read — a subclass of a View stays a view. */
10
+ export declare function isView(t: any): boolean;
8
11
  export declare function installViewInterop(): void;
package/dist/view.js CHANGED
@@ -3,7 +3,7 @@ import { useLayoutEffect, useRef } from 'preact/hooks';
3
3
  import { InjectorContext, useInjector } from './context';
4
4
  import { Injector } from './injector';
5
5
  import { registerModule } from './module';
6
- import { ownerOf } from './decorators';
6
+ import { ownerOf, getComponentMeta } from './decorators';
7
7
  // --- Class-based views -----------------------------------------------------
8
8
  // A View subclass is never instantiated by Preact. The options.vnode hook
9
9
  // below swaps any vnode whose type is a View subclass for a host function
@@ -23,8 +23,13 @@ export class View extends PreactComponent {
23
23
  throw new Error('View components are signal-driven — assign a signal instead of calling forceUpdate()');
24
24
  }
25
25
  }
26
- function isViewClass(t) {
27
- return typeof t === 'function' && t.prototype instanceof View;
26
+ // Identity stamp, not instanceof: statics inherit through the constructor
27
+ // chain, so subclasses (and subclasses of subclasses) read true, and a
28
+ // view class built against a second copy of this package still interops.
29
+ View.__view = true;
30
+ /** Identity stamp read — a subclass of a View stays a view. */
31
+ export function isView(t) {
32
+ return typeof t === 'function' && !!t.__view;
28
33
  }
29
34
  // --- Error boundary ----------------------------------------------------------
30
35
  // Real Preact class component (not a View — it must be instantiated by Preact).
@@ -61,7 +66,7 @@ function hostFor(cls) {
61
66
  let host = hostCache.get(cls);
62
67
  if (host)
63
68
  return host;
64
- const meta = (cls.__component ?? {});
69
+ const meta = getComponentMeta(cls) ?? {};
65
70
  const inner = function ViewHost(props) {
66
71
  const parent = useInjector();
67
72
  const ref = useRef(null);
@@ -125,7 +130,7 @@ export function installViewInterop() {
125
130
  const prev = options.vnode;
126
131
  options.vnode = (vnode) => {
127
132
  const t = vnode.type;
128
- if (isViewClass(t))
133
+ if (isView(t))
129
134
  vnode.type = hostFor(t);
130
135
  prev?.(vnode);
131
136
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@preact-mvc/core",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
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,