@finesoft/create-app 0.1.27 → 0.1.29

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.
Files changed (28) hide show
  1. package/package.json +1 -1
  2. package/templates/react/package.json +1 -1
  3. package/templates/react-minimal/package.json +1 -1
  4. package/templates/react-minimal/src/App.tsx +101 -15
  5. package/templates/react-minimal/src/bootstrap.ts +35 -2
  6. package/templates/react-minimal/src/lib/controllers/detail.ts +22 -0
  7. package/templates/react-minimal/src/lib/controllers/home.ts +23 -4
  8. package/templates/react-minimal/src/lib/controllers/notes.ts +16 -0
  9. package/templates/react-minimal/src/main.tsx +90 -35
  10. package/templates/react-minimal/src/ssr.tsx +37 -11
  11. package/templates/react-minimal/src/views/DetailView.tsx +28 -0
  12. package/templates/react-minimal/src/views/HomeView.tsx +27 -0
  13. package/templates/react-minimal/src/views/NotesView.tsx +11 -0
  14. package/templates/svelte/package.json +1 -1
  15. package/templates/svelte-minimal/package.json +1 -1
  16. package/templates/vue/package.json +1 -1
  17. package/templates/vue-minimal/package.json +1 -1
  18. package/templates/vue-minimal/src/App.vue +54 -13
  19. package/templates/vue-minimal/src/bootstrap.ts +35 -2
  20. package/templates/vue-minimal/src/lib/controllers/detail.ts +22 -0
  21. package/templates/vue-minimal/src/lib/controllers/home.ts +22 -4
  22. package/templates/vue-minimal/src/lib/controllers/notes.ts +16 -0
  23. package/templates/vue-minimal/src/main.ts +65 -31
  24. package/templates/vue-minimal/src/ssr.ts +36 -14
  25. package/templates/vue-minimal/src/views/DetailView.vue +29 -0
  26. package/templates/vue-minimal/src/views/HomeView.vue +26 -0
  27. package/templates/vue-minimal/src/views/NotesView.vue +12 -0
  28. package/templates/vue-minimal/src/pages/Home.vue +0 -12
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@finesoft/create-app",
3
- "version": "0.1.27",
3
+ "version": "0.1.29",
4
4
  "description": "Scaffold a new Finesoft Front project",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -8,7 +8,7 @@
8
8
  "preview": "vp preview"
9
9
  },
10
10
  "dependencies": {
11
- "@finesoft/front": "^0.2.0",
11
+ "@finesoft/front": "^0.4.0",
12
12
  "react": "^19.0.0",
13
13
  "react-dom": "^19.0.0"
14
14
  },
@@ -8,7 +8,7 @@
8
8
  "preview": "vp preview"
9
9
  },
10
10
  "dependencies": {
11
- "@finesoft/front": "^0.2.0",
11
+ "@finesoft/front": "^0.4.0",
12
12
  "react": "^19.0.0",
13
13
  "react-dom": "^19.0.0"
14
14
  },
@@ -1,22 +1,108 @@
1
- import type { Action, BasePage } from "@finesoft/front";
1
+ import {
2
+ isStackNode,
3
+ isTabsNode,
4
+ type AppHandle,
5
+ type NavigationHandle,
6
+ type NavigationSnapshot,
7
+ } from "@finesoft/front";
8
+ import { useEffect, useState } from "react";
2
9
 
3
- interface AppProps {
4
- page?: BasePage | null;
5
- loading?: boolean;
6
- onAction?: (action: Action) => void;
10
+ /** name 全局切片的极小外部 store 接口(main 创建并注入;App 只读它 + 订阅)。 */
11
+ export interface NameStore {
12
+ get(): string;
13
+ set(value: string): void;
14
+ subscribe(listener: () => void): () => void;
7
15
  }
8
16
 
9
- export default function App({ page, loading = false }: AppProps) {
10
- if (loading)
11
- return (
12
- <main style={{ padding: "2rem", textAlign: "center", color: "#999" }}>Loading…</main>
13
- );
14
- if (!page) return null;
17
+ export interface AppProps {
18
+ /** 首屏快照:SSR 与客户端 hydrate 时一致(URL 推导,tree 相同)→ 无水合失配。 */
19
+ initialSnapshot: NavigationSnapshot | null;
20
+ /** 导航 handle(客户端有;SSR 无)。提供后订阅 snapshot 变更驱动重渲。 */
21
+ nav?: NavigationHandle;
22
+ /** 框架统一句柄(selectTab / pop / save)。SSR 无(渲染不依赖,仅事件处理用)。 */
23
+ controller?: AppHandle;
24
+ /** name 切片 store(客户端注入;SSR 无 → name 恒 "")。 */
25
+ nameStore?: NameStore;
26
+ }
27
+
28
+ const TAB_LABELS: Record<string, string> = { home: "Feed", notes: "Notes" };
29
+
30
+ export default function App({ initialSnapshot, nav, controller, nameStore }: AppProps) {
31
+ // 首渲用 initialSnapshot(= SSR 快照,tree 一致)→ 水合 DOM 一致;effect 仅客户端跑,订阅后续提交。
32
+ const [snapshot, setSnapshot] = useState(initialSnapshot);
33
+ useEffect(() => {
34
+ if (!nav) return;
35
+ setSnapshot(nav.getSnapshot()); // 追平 render→effect 间隙可能的提交
36
+ return nav.subscribe(setSnapshot);
37
+ }, [nav]);
38
+
39
+ // name 恒 "" 首渲(SSR + 水合一致);会话恢复在水合后经 store 落,订阅驱动重渲。
40
+ const [name, setName] = useState("");
41
+ useEffect(() => {
42
+ if (!nameStore) return;
43
+ setName(nameStore.get());
44
+ return nameStore.subscribe(() => setName(nameStore.get()));
45
+ }, [nameStore]);
46
+
47
+ const tree = snapshot?.tree ?? null;
48
+ const tabBar = tree && isTabsNode(tree) ? { order: tree.order, active: tree.active } : null;
49
+ const branch = tree && isTabsNode(tree) ? tree.branches[tree.active] : null;
50
+ const canGoBack = !!branch && isStackNode(branch) && branch.entries.length > 1;
15
51
 
16
52
  return (
17
- <main style={{ padding: "1rem" }}>
18
- <h1>{page.title}</h1>
19
- <p>{page.description}</p>
20
- </main>
53
+ <div
54
+ style={{
55
+ maxWidth: "32rem",
56
+ margin: "0 auto",
57
+ padding: "1rem",
58
+ fontFamily: "system-ui",
59
+ }}
60
+ >
61
+ {/* 全局切片:名字 */}
62
+ <header
63
+ style={{
64
+ display: "flex",
65
+ gap: "0.5rem",
66
+ alignItems: "center",
67
+ marginBottom: "1rem",
68
+ }}
69
+ >
70
+ <label style={{ flex: 1 }}>
71
+ Your name (global):
72
+ <input
73
+ value={name}
74
+ placeholder="anon"
75
+ onChange={(e) => {
76
+ setName(e.target.value);
77
+ nameStore?.set(e.target.value);
78
+ }}
79
+ onBlur={() => void controller?.save()}
80
+ />
81
+ </label>
82
+ {name && <span>👋 {name}</span>}
83
+ </header>
84
+
85
+ {/* TabView */}
86
+ {tabBar && (
87
+ <nav style={{ display: "flex", gap: "0.5rem", marginBottom: "1rem" }}>
88
+ {tabBar.order.map((key) => (
89
+ <button
90
+ key={key}
91
+ style={{ fontWeight: key === tabBar.active ? 700 : 400 }}
92
+ aria-current={key === tabBar.active}
93
+ onClick={() => void controller?.selectTab(key)}
94
+ >
95
+ {TAB_LABELS[key] ?? key}
96
+ </button>
97
+ ))}
98
+ </nav>
99
+ )}
100
+
101
+ {canGoBack && (
102
+ <button style={{ marginBottom: "0.5rem" }} onClick={() => void controller?.pop()}>
103
+ ← Back
104
+ </button>
105
+ )}
106
+ </div>
21
107
  );
22
108
  }
@@ -1,6 +1,39 @@
1
- import { type Framework, defineRoutes } from "@finesoft/front";
1
+ import { Framework, defineNavigation, defineRoutes, leaf, stack, tabs } from "@finesoft/front";
2
+ import { DetailController } from "./lib/controllers/detail";
2
3
  import { HomeController } from "./lib/controllers/home";
4
+ import { NotesController } from "./lib/controllers/notes";
3
5
 
6
+ /**
7
+ * 同一份 bootstrap 在浏览器与服务器都会执行:注册 controllers + 路由。
8
+ * 路由让 URL 能 resolve(SSR 首屏 / codec / 扁平回退都需要)。
9
+ */
4
10
  export function bootstrap(framework: Framework): void {
5
- defineRoutes(framework, [{ path: "/", intentId: "home", controller: new HomeController() }]);
11
+ defineRoutes(framework, [
12
+ { path: "/", intentId: "home", controller: new HomeController() },
13
+ { path: "/item/:id", intentId: "detail", controller: new DetailController() },
14
+ { path: "/notes", intentId: "notes", controller: new NotesController() },
15
+ ]);
6
16
  }
17
+
18
+ /**
19
+ * 结构化导航:一个 TabView(Home / Notes),每个 tab 是一个 NavigationStack。
20
+ * - Home 栈可 push 到 detail(栈深 +1),返回 pop(栈深 -1)。
21
+ * - 切 tab 保活另一分支的栈深与作用域状态(对标 SwiftUI TabView)。
22
+ *
23
+ * `initial` 用 **URL→树 工厂**(而非静态树):这样深链 / 刷新 `/item/2` 能重建出
24
+ * 「Home 栈压着 detail」的结构,active-leaf codec 也把它回写成 `/item/2`,刷新不再跳回 `/`。
25
+ * (静态树会忽略 URL、永远从 home 起,刷新被改回 `/`。)
26
+ */
27
+ export const navigation = defineNavigation({
28
+ initial: (url) => {
29
+ const path = url.split("?")[0].split("#")[0];
30
+ const detail = /^\/item\/(.+)$/.exec(path);
31
+ const home = detail
32
+ ? stack([leaf("home"), leaf("detail", { id: detail[1] })])
33
+ : stack(leaf("home"));
34
+ return tabs({
35
+ active: path === "/notes" ? "notes" : "home",
36
+ branches: { home, notes: stack(leaf("notes")) },
37
+ });
38
+ },
39
+ });
@@ -0,0 +1,22 @@
1
+ import { BaseController, type BasePage } from "@finesoft/front";
2
+
3
+ /** Detail 页面:由 push("detail", { id }) 进入;id 进 params。 */
4
+ export interface DetailPage extends BasePage {
5
+ readonly itemId: string;
6
+ }
7
+
8
+ export class DetailController extends BaseController<{ id?: string }, DetailPage> {
9
+ readonly intentId = "detail";
10
+
11
+ execute(params: { id?: string }): DetailPage {
12
+ const id = params.id ?? "?";
13
+ return {
14
+ id: `detail-${id}`,
15
+ pageType: "detail",
16
+ url: `/item/${id}`,
17
+ title: `Item ${id}`,
18
+ description: "A pushed detail screen. Its scoped note is lost once you pop it.",
19
+ itemId: id,
20
+ };
21
+ }
22
+ }
@@ -1,14 +1,33 @@
1
1
  import { BaseController, type BasePage } from "@finesoft/front";
2
2
 
3
- export class HomeController extends BaseController<Record<string, string>, BasePage> {
3
+ /** 一个 feed 项。 */
4
+ export interface FeedItem {
5
+ readonly id: string;
6
+ readonly title: string;
7
+ }
8
+
9
+ /** Home(feed)页面:携带可 push 进 detail 的列表项。 */
10
+ export interface FeedPage extends BasePage {
11
+ readonly items: readonly FeedItem[];
12
+ }
13
+
14
+ const ITEMS: readonly FeedItem[] = [
15
+ { id: "1", title: "Structured navigation" },
16
+ { id: "2", title: "Session restoration" },
17
+ { id: "3", title: "Navigation-scoped state" },
18
+ ];
19
+
20
+ export class HomeController extends BaseController<Record<string, string>, FeedPage> {
4
21
  readonly intentId = "home";
5
22
 
6
- execute(): BasePage {
23
+ execute(): FeedPage {
7
24
  return {
8
25
  id: "home",
9
26
  pageType: "home",
10
- title: "Home",
11
- description: "Welcome to Finesoft Front",
27
+ url: "/",
28
+ title: "Feed",
29
+ description: "Tap an item to push a detail screen.",
30
+ items: ITEMS,
12
31
  };
13
32
  }
14
33
  }
@@ -0,0 +1,16 @@
1
+ import { BaseController, type BasePage } from "@finesoft/front";
2
+
3
+ /** Notes 页面:第二个 tab,演示切 tab 保活该分支的作用域状态。 */
4
+ export class NotesController extends BaseController<Record<string, string>, BasePage> {
5
+ readonly intentId = "notes";
6
+
7
+ execute(): BasePage {
8
+ return {
9
+ id: "notes",
10
+ pageType: "notes",
11
+ url: "/notes",
12
+ title: "Notes",
13
+ description: "This textarea is navigation-scoped — switch tabs and it survives.",
14
+ };
15
+ }
16
+ }
@@ -1,44 +1,99 @@
1
- import { startBrowserApp, type Action, type BasePage, type Framework } from "@finesoft/front";
2
- import { createRoot } from "react-dom/client";
3
- import App from "./App";
4
- import { bootstrap } from "./bootstrap";
1
+ import {
2
+ resolveIslandsShell,
3
+ startBrowserApp,
4
+ type AppHandle,
5
+ type BasePage,
6
+ type MountEntry,
7
+ type SessionStateProvider,
8
+ } from "@finesoft/front";
9
+ import type { ComponentType } from "react";
10
+ import { flushSync } from "react-dom";
11
+ import { createRoot, hydrateRoot } from "react-dom/client";
12
+ import App, { type NameStore } from "./App";
13
+ import { bootstrap, navigation } from "./bootstrap";
14
+ import DetailView from "./views/DetailView";
15
+ import HomeView from "./views/HomeView";
16
+ import NotesView from "./views/NotesView";
5
17
 
6
- void startBrowserApp({
7
- bootstrap,
8
- mountId: "app",
9
- mount: (target: HTMLElement, { framework }: { framework: Framework }) => {
10
- let root: ReturnType<typeof createRoot> | null = null;
11
- let currentPage: BasePage | null = null;
18
+ /** name 全局切片的极小外部 store —— React 无内建响应式,跨组件共享需显式 store(mount 前建好交给 provider + App)。 */
19
+ function createNameStore(): NameStore {
20
+ let value = "";
21
+ const listeners = new Set<() => void>();
22
+ return {
23
+ get: () => value,
24
+ set: (v) => {
25
+ value = v;
26
+ listeners.forEach((l) => l());
27
+ },
28
+ subscribe: (l) => {
29
+ listeners.add(l);
30
+ return () => listeners.delete(l);
31
+ },
32
+ };
33
+ }
34
+ const nameStore = createNameStore();
35
+
36
+ /** 全局切片(app-wide):用户名字 —— 跨 tab、跨重载都在(对标 SwiftUI @SceneStorage)。 */
37
+ const profileProvider: SessionStateProvider = {
38
+ key: "profile",
39
+ capture: () => ({ name: nameStore.get() }),
40
+ restore: (data) => nameStore.set((data as { name?: string }).name ?? ""),
41
+ };
42
+
43
+ /** islands 闭包引用:mount 回调赋值,islands(mount 后挂)读时已就绪。 */
44
+ let controller: AppHandle | undefined;
12
45
 
13
- function render(page: BasePage | null, loading: boolean) {
14
- const handleAction = (action: Action) => {
15
- void framework.perform(action);
16
- };
17
- if (!root) root = createRoot(target);
18
- root.render(<App page={page} loading={loading} onAction={handleAction} />);
19
- }
46
+ /** intent 视图组件。islands entry 挂为独立 React root。 */
47
+ const VIEWS: Record<string, ComponentType<{ page: BasePage; controller?: AppHandle }>> = {
48
+ home: HomeView,
49
+ detail: DetailView,
50
+ notes: NotesView,
51
+ };
20
52
 
21
- return ({
22
- page,
23
- isFirstPage,
24
- }: {
25
- page: Promise<BasePage> | BasePage;
26
- isFirstPage?: boolean;
27
- }) => {
28
- if (page instanceof Promise) {
29
- if (!isFirstPage) render(currentPage, true);
30
- void page.then((p) => {
31
- currentPage = p;
32
- render(currentPage, false);
33
- });
34
- } else {
35
- currentPage = page;
36
- render(currentPage, false);
37
- }
38
- };
53
+ const mountEntry: MountEntry = (entry, container) => {
54
+ const View = VIEWS[entry.intent] ?? HomeView;
55
+ const element = <View page={entry.page} controller={controller} />;
56
+ // 首屏 island 已由 SSR 渲入容器(编排器收养并置 hydrate:true)→ 水合;否则新建。
57
+ if (entry.hydrate) {
58
+ const root = hydrateRoot(container, element);
59
+ return { unmount: () => root.unmount() };
60
+ }
61
+ const root = createRoot(container);
62
+ // flushSync 强制同步提交:domRestore 在 fs:enter 后用 rAF 回填 data-restore-root 字段,
63
+ // React 的 root.render() 默认异步提交 DOM,rAF 会赢得竞态、字段元素尚不存在 → 回填扑空
64
+ // (从根路径恢复到深层 URL 时,该 island 是客户端新挂、非 SSR,必踩)。同步提交贴合 domRestore
65
+ // 假设的「挂载即 DOM 就绪」契约(Vue/Svelte 的 .mount() 天然同步满足)。
66
+ flushSync(() => root.render(element));
67
+ return { unmount: () => root.unmount() };
68
+ };
69
+
70
+ void startBrowserApp({
71
+ bootstrap,
72
+ mount(target, ctx) {
73
+ controller = ctx.app; // islands(mount 后挂)与 chrome 共用
74
+ // 方案 C:chrome 挂到 sibling chrome-root(不含 outlet);outlet 由框架编排器独占。
75
+ const { chromeRoot, hydrate } = resolveIslandsShell(target);
76
+ const initialSnapshot = ctx.navigation?.getSnapshot() ?? null;
77
+ const element = (
78
+ <App
79
+ initialSnapshot={initialSnapshot}
80
+ nav={ctx.navigation}
81
+ controller={ctx.app}
82
+ nameStore={nameStore}
83
+ />
84
+ );
85
+ if (hydrate) hydrateRoot(chromeRoot, element);
86
+ else createRoot(chromeRoot).render(element);
87
+ return () => undefined;
39
88
  },
40
89
  callbacks: {
41
90
  onNavigate() {},
42
91
  onModal() {},
43
92
  },
93
+ // 结构化导航 + islands:每屏 per-entry 挂为独立 root、保活。
94
+ navigation: { ...navigation.toBrowserConfig(), mountEntry },
95
+ // 重载 DOM 自动恢复:data-restore-root 内字段/滚动自动捕获回填。
96
+ domRestore: true,
97
+ // 会话恢复:注册全局切片 provider;导航位置 + 作用域状态由框架自动捕获。
98
+ session: { providers: [profileProvider] },
44
99
  });
@@ -1,25 +1,51 @@
1
- import { createSSRRender, serializeServerData } from "@finesoft/front";
1
+ import {
2
+ createSSRNavigationRender,
3
+ renderIslandsHtml,
4
+ serializeServerData,
5
+ type BasePage,
6
+ type ResolvedEntry,
7
+ } from "@finesoft/front";
8
+ import type { ComponentType } from "react";
2
9
  import { renderToString } from "react-dom/server";
3
10
  import App from "./App";
4
- import { bootstrap } from "./bootstrap";
11
+ import { bootstrap, navigation } from "./bootstrap";
12
+ import DetailView from "./views/DetailView";
13
+ import HomeView from "./views/HomeView";
14
+ import NotesView from "./views/NotesView";
5
15
 
6
- export const render = createSSRRender({
16
+ const VIEWS: Record<string, ComponentType<{ page: BasePage }>> = {
17
+ home: HomeView,
18
+ detail: DetailView,
19
+ notes: NotesView,
20
+ };
21
+
22
+ /**
23
+ * islands 架构 SSR(方案 C):
24
+ * - chrome(App,header + tabbar)渲进 `<div data-fs-chrome>`。
25
+ * - 可见 island 内容由 `renderIslandsHtml` 渲进 sibling `<main data-fs-outlet>`,客户端按 key 收养水合。
26
+ *
27
+ * chrome 水合 props parity:客户端 mount 时 navigation.getSnapshot() 即此 URL 推导 snapshot
28
+ * (tree 一致)→ chrome 水合无失配,且 nav bar 首屏即被 SSR 渲出。name 仍默认 ""(会话恢复在
29
+ * mount 后、水合后才生效)。nav/controller/nameStore 仅事件与订阅用,不影响 SSR DOM,可省。
30
+ */
31
+ export const render = createSSRNavigationRender({
7
32
  bootstrap,
8
33
  getErrorPage(status, message) {
9
- return {
10
- id: "error",
11
- pageType: "error",
12
- title: `Error ${status}`,
13
- description: message,
14
- };
34
+ return { id: "error", pageType: "error", title: `Error ${status}`, description: message };
15
35
  },
16
- renderApp(page, _framework) {
36
+ async renderApp(page, _framework, snapshot) {
37
+ const chromeHtml = renderToString(<App initialSnapshot={snapshot} />);
38
+ const islandsHtml = await renderIslandsHtml(snapshot, (entry: ResolvedEntry) => {
39
+ const View = VIEWS[entry.intent] ?? HomeView;
40
+ return renderToString(<View page={entry.page} />);
41
+ });
17
42
  return {
18
- html: renderToString(<App page={page} />),
43
+ html: `<div data-fs-chrome>${chromeHtml}</div><main data-fs-outlet>${islandsHtml}</main>`,
19
44
  head: `<title>${page.title}</title>`,
20
45
  css: "",
21
46
  };
22
47
  },
48
+ navigation: navigation.toSSRDefinition(),
23
49
  });
24
50
 
25
51
  export { serializeServerData };
@@ -0,0 +1,28 @@
1
+ import type { BasePage } from "@finesoft/front";
2
+
3
+ /**
4
+ * Detail:由 push("detail", { id }) 进入。
5
+ *
6
+ * 零样板保活:标一个 data-restore-root,里面的裸 <input>(非受控)即自动:
7
+ * - in-session:push 走、pop 回来值还在(islands 保活,实例没销毁)
8
+ * - 重载:sessionStorage 回填(domRestore),合成事件驱动可能的受控绑定
9
+ * 注意必须用**非受控** input(无 value/onChange)——否则 React 会盖掉 domRestore 的命令式写值。
10
+ */
11
+ export default function DetailView({ page }: { page: BasePage }) {
12
+ return (
13
+ <section>
14
+ <h1 style={{ margin: "0 0 0.25rem" }}>{page.title}</h1>
15
+ <p style={{ color: "#666", margin: "0 0 1rem" }}>{page.description}</p>
16
+ <div data-restore-root>
17
+ <label style={{ display: "block", marginTop: "1rem" }}>
18
+ Draft note for this screen:
19
+ <input
20
+ name="note"
21
+ placeholder="kept while alive; restored on reload"
22
+ style={{ width: "100%" }}
23
+ />
24
+ </label>
25
+ </div>
26
+ </section>
27
+ );
28
+ }
@@ -0,0 +1,27 @@
1
+ import type { AppHandle, BasePage } from "@finesoft/front";
2
+ import type { FeedPage } from "../lib/controllers/home";
3
+
4
+ /** Home(feed):列表项点击 push 进 detail。 */
5
+ export default function HomeView({ page, controller }: { page: BasePage; controller?: AppHandle }) {
6
+ const feed = page.pageType === "home" ? (page as FeedPage) : null;
7
+ return (
8
+ <section>
9
+ <h1 style={{ margin: "0 0 0.25rem" }}>{page.title}</h1>
10
+ <p style={{ color: "#666", margin: "0 0 1rem" }}>{page.description}</p>
11
+ {feed && (
12
+ <ul style={{ listStyle: "none", padding: 0, display: "grid", gap: "0.5rem" }}>
13
+ {feed.items.map((item) => (
14
+ <li key={item.id}>
15
+ <button
16
+ style={{ width: "100%", textAlign: "left" }}
17
+ onClick={() => void controller?.push("detail", { id: item.id })}
18
+ >
19
+ {item.title} →
20
+ </button>
21
+ </li>
22
+ ))}
23
+ </ul>
24
+ )}
25
+ </section>
26
+ );
27
+ }
@@ -0,0 +1,11 @@
1
+ import type { BasePage } from "@finesoft/front";
2
+
3
+ /** Notes:第二个 tab,纯展示(切 tab 保活由 islands 负责)。 */
4
+ export default function NotesView({ page }: { page: BasePage }) {
5
+ return (
6
+ <section>
7
+ <h1 style={{ margin: "0 0 0.25rem" }}>{page.title}</h1>
8
+ <p style={{ color: "#666", margin: "0 0 1rem" }}>{page.description}</p>
9
+ </section>
10
+ );
11
+ }
@@ -8,7 +8,7 @@
8
8
  "preview": "vp preview"
9
9
  },
10
10
  "dependencies": {
11
- "@finesoft/front": "^0.2.0",
11
+ "@finesoft/front": "^0.4.0",
12
12
  "svelte": "^5.0.0"
13
13
  },
14
14
  "devDependencies": {
@@ -8,7 +8,7 @@
8
8
  "preview": "vp preview"
9
9
  },
10
10
  "dependencies": {
11
- "@finesoft/front": "^0.2.0",
11
+ "@finesoft/front": "^0.4.0",
12
12
  "svelte": "^5.0.0"
13
13
  },
14
14
  "devDependencies": {
@@ -8,7 +8,7 @@
8
8
  "preview": "vp preview"
9
9
  },
10
10
  "dependencies": {
11
- "@finesoft/front": "^0.2.0",
11
+ "@finesoft/front": "^0.4.0",
12
12
  "vue": "^3.5.0"
13
13
  },
14
14
  "devDependencies": {
@@ -8,7 +8,7 @@
8
8
  "preview": "vp preview"
9
9
  },
10
10
  "dependencies": {
11
- "@finesoft/front": "^0.2.0",
11
+ "@finesoft/front": "^0.4.0",
12
12
  "vue": "^3.5.0"
13
13
  },
14
14
  "devDependencies": {
@@ -1,21 +1,62 @@
1
1
  <script setup lang="ts">
2
- import type { Action, BasePage } from "@finesoft/front";
2
+ import { isStackNode, isTabsNode } from "@finesoft/front";
3
3
  import { computed } from "vue";
4
- import Home from "./pages/Home.vue";
4
+ import type { AppController, AppState } from "./main";
5
5
 
6
- const { state, page: ssrPage } = defineProps<{
7
- state?: { page: BasePage | null; loading: boolean };
8
- page?: BasePage;
9
- onAction?: (action: Action) => void;
10
- }>();
6
+ const { state, controller } = defineProps<{ state?: AppState; controller?: AppController }>();
11
7
 
12
- const currentPage = computed(() => state?.page ?? ssrPage ?? null);
13
- const loading = computed(() => state?.loading ?? false);
8
+ const tree = computed(() => state?.snapshot?.tree ?? null);
9
+
10
+ /** Tab bar(tree 为 tabs 节点时)。 */
11
+ const tabs = computed(() => {
12
+ const t = tree.value;
13
+ return t && isTabsNode(t) ? { order: t.order, active: t.active } : null;
14
+ });
15
+ const tabLabels: Record<string, string> = { home: "Feed", notes: "Notes" };
16
+
17
+ /** 激活 tab 的栈深 > 1 → 可返回。 */
18
+ const canGoBack = computed(() => {
19
+ const t = tree.value;
20
+ if (!t || !isTabsNode(t)) return false;
21
+ const branch = t.branches[t.active];
22
+ return !!branch && isStackNode(branch) && branch.entries.length > 1;
23
+ });
24
+
25
+ /** 全局切片:名字(跨 tab / 跨重载)。 */
26
+ const name = computed({
27
+ get: () => state?.name ?? "",
28
+ set: (v) => {
29
+ if (state) state.name = v;
30
+ },
31
+ });
14
32
  </script>
15
33
 
16
34
  <template>
17
- <main style="padding: 1rem">
18
- <p v-if="loading" style="text-align: center; color: #999">Loading…</p>
19
- <Home v-else-if="currentPage" :page="currentPage" />
20
- </main>
35
+ <div style="max-width: 32rem; margin: 0 auto; padding: 1rem; font-family: system-ui">
36
+ <!-- 全局切片:名字 -->
37
+ <header style="display: flex; gap: 0.5rem; align-items: center; margin-bottom: 1rem">
38
+ <label v-if="state" style="flex: 1">
39
+ Your name (global):
40
+ <input v-model="name" placeholder="anon" @blur="controller?.save()" />
41
+ </label>
42
+ <span v-if="name">👋 {{ name }}</span>
43
+ </header>
44
+
45
+ <!-- TabView -->
46
+ <nav v-if="tabs" style="display: flex; gap: 0.5rem; margin-bottom: 1rem">
47
+ <button
48
+ v-for="key in tabs.order"
49
+ :key="key"
50
+ :style="{ fontWeight: key === tabs.active ? '700' : '400' }"
51
+ :aria-current="key === tabs.active"
52
+ @click="controller?.selectTab(key)"
53
+ >
54
+ {{ tabLabels[key] ?? key }}
55
+ </button>
56
+ </nav>
57
+
58
+ <button v-if="canGoBack" style="margin-bottom: 0.5rem" @click="controller?.pop()">
59
+ ← Back
60
+ </button>
61
+ </div>
21
62
  </template>
@@ -1,6 +1,39 @@
1
- import { Framework, defineRoutes } from "@finesoft/front";
1
+ import { Framework, defineNavigation, defineRoutes, leaf, stack, tabs } from "@finesoft/front";
2
+ import { DetailController } from "./lib/controllers/detail";
2
3
  import { HomeController } from "./lib/controllers/home";
4
+ import { NotesController } from "./lib/controllers/notes";
3
5
 
6
+ /**
7
+ * 同一份 bootstrap 在浏览器与服务器都会执行:注册 controllers + 路由。
8
+ * 路由让 URL 能 resolve(SSR 首屏 / codec / 扁平回退都需要)。
9
+ */
4
10
  export function bootstrap(framework: Framework): void {
5
- defineRoutes(framework, [{ path: "/", intentId: "home", controller: new HomeController() }]);
11
+ defineRoutes(framework, [
12
+ { path: "/", intentId: "home", controller: new HomeController() },
13
+ { path: "/item/:id", intentId: "detail", controller: new DetailController() },
14
+ { path: "/notes", intentId: "notes", controller: new NotesController() },
15
+ ]);
6
16
  }
17
+
18
+ /**
19
+ * 结构化导航:一个 TabView(Home / Notes),每个 tab 是一个 NavigationStack。
20
+ * - Home 栈可 push 到 detail(栈深 +1),返回 pop(栈深 -1)。
21
+ * - 切 tab 保活另一分支的栈深与作用域状态(对标 SwiftUI TabView)。
22
+ *
23
+ * `initial` 用 **URL→树 工厂**(而非静态树):这样深链 / 刷新 `/item/2` 能重建出
24
+ * 「Home 栈压着 detail」的结构,active-leaf codec 也把它回写成 `/item/2`,刷新不再跳回 `/`。
25
+ * (静态树会忽略 URL、永远从 home 起,刷新被改回 `/`。)
26
+ */
27
+ export const navigation = defineNavigation({
28
+ initial: (url) => {
29
+ const path = url.split("?")[0].split("#")[0];
30
+ const detail = /^\/item\/(.+)$/.exec(path);
31
+ const home = detail
32
+ ? stack([leaf("home"), leaf("detail", { id: detail[1] })])
33
+ : stack(leaf("home"));
34
+ return tabs({
35
+ active: path === "/notes" ? "notes" : "home",
36
+ branches: { home, notes: stack(leaf("notes")) },
37
+ });
38
+ },
39
+ });
@@ -0,0 +1,22 @@
1
+ import { BaseController, type BasePage } from "@finesoft/front";
2
+
3
+ /** Detail 页面:由 push("detail", { id }) 进入;id 进 params。 */
4
+ export interface DetailPage extends BasePage {
5
+ readonly itemId: string;
6
+ }
7
+
8
+ export class DetailController extends BaseController<{ id?: string }, DetailPage> {
9
+ readonly intentId = "detail";
10
+
11
+ execute(params: { id?: string }): DetailPage {
12
+ const id = params.id ?? "?";
13
+ return {
14
+ id: `detail-${id}`,
15
+ pageType: "detail",
16
+ url: `/item/${id}`,
17
+ title: `Item ${id}`,
18
+ description: "A pushed detail screen. Its scoped note is lost once you pop it.",
19
+ itemId: id,
20
+ };
21
+ }
22
+ }
@@ -1,15 +1,33 @@
1
1
  import { BaseController, type BasePage } from "@finesoft/front";
2
2
 
3
- export class HomeController extends BaseController<Record<string, string>, BasePage> {
3
+ /** 一个 feed 项。 */
4
+ export interface FeedItem {
5
+ readonly id: string;
6
+ readonly title: string;
7
+ }
8
+
9
+ /** Home(feed)页面:携带可 push 进 detail 的列表项。 */
10
+ export interface FeedPage extends BasePage {
11
+ readonly items: readonly FeedItem[];
12
+ }
13
+
14
+ const ITEMS: readonly FeedItem[] = [
15
+ { id: "1", title: "Structured navigation" },
16
+ { id: "2", title: "Session restoration" },
17
+ { id: "3", title: "Navigation-scoped state" },
18
+ ];
19
+
20
+ export class HomeController extends BaseController<Record<string, string>, FeedPage> {
4
21
  readonly intentId = "home";
5
22
 
6
- execute(): BasePage {
23
+ execute(): FeedPage {
7
24
  return {
8
25
  id: "home",
9
26
  pageType: "home",
10
27
  url: "/",
11
- title: "Home",
12
- description: "Welcome to Finesoft Front",
28
+ title: "Feed",
29
+ description: "Tap an item to push a detail screen.",
30
+ items: ITEMS,
13
31
  };
14
32
  }
15
33
  }
@@ -0,0 +1,16 @@
1
+ import { BaseController, type BasePage } from "@finesoft/front";
2
+
3
+ /** Notes 页面:第二个 tab,演示切 tab 保活该分支的作用域状态。 */
4
+ export class NotesController extends BaseController<Record<string, string>, BasePage> {
5
+ readonly intentId = "notes";
6
+
7
+ execute(): BasePage {
8
+ return {
9
+ id: "notes",
10
+ pageType: "notes",
11
+ url: "/notes",
12
+ title: "Notes",
13
+ description: "This textarea is navigation-scoped — switch tabs and it survives.",
14
+ };
15
+ }
16
+ }
@@ -1,41 +1,75 @@
1
- import { startBrowserApp, type Action, type BasePage, type Framework } from "@finesoft/front";
2
- import { createApp, reactive } from "vue";
1
+ import {
2
+ resolveIslandsShell,
3
+ startBrowserApp,
4
+ type AppHandle,
5
+ type MountEntry,
6
+ type NavigationSnapshot,
7
+ type SessionStateProvider,
8
+ } from "@finesoft/front";
9
+ import { createApp, createSSRApp, markRaw, reactive, type Component } from "vue";
3
10
  import App from "./App.vue";
4
- import { bootstrap } from "./bootstrap";
11
+ import HomeView from "./views/HomeView.vue";
12
+ import DetailView from "./views/DetailView.vue";
13
+ import NotesView from "./views/NotesView.vue";
14
+ import { bootstrap, navigation } from "./bootstrap";
15
+
16
+ /** 只把要渲染的数据放进 reactive;handle 是带闭包的复杂对象,留在模块作用域不被代理。 */
17
+ export interface AppState {
18
+ snapshot: NavigationSnapshot | null;
19
+ name: string;
20
+ }
21
+ export type AppController = AppHandle; // 组件 prop 类型 = 框架统一句柄
22
+
23
+ const state = reactive<AppState>({ snapshot: null, name: "" });
24
+
25
+ /** 全局切片(app-wide):用户名字 —— 跨 tab、跨重载都在(对标 SwiftUI @SceneStorage)。 */
26
+ const profileProvider: SessionStateProvider = {
27
+ key: "profile",
28
+ capture: () => ({ name: state.name }),
29
+ restore: (data) => {
30
+ state.name = (data as { name?: string }).name ?? "";
31
+ },
32
+ };
33
+
34
+ /** islands 闭包引用:mount 回调赋值,islands(mount 后挂)读时已就绪。 */
35
+ let controller: AppHandle | undefined;
36
+
37
+ /** intent → 视图组件。islands 按 entry 挂为独立 Vue app。 */
38
+ const VIEWS: Record<string, Component> = { home: HomeView, detail: DetailView, notes: NotesView };
39
+
40
+ const mountEntry: MountEntry = (entry, container) => {
41
+ const view = VIEWS[entry.intent] ?? HomeView;
42
+ // 首屏 island 已由 SSR 渲入容器(编排器收养并置 hydrate:true)→ 水合;否则新建。
43
+ const factory = entry.hydrate ? createSSRApp : createApp;
44
+ const app = factory(view, { page: entry.page, controller });
45
+ app.mount(container);
46
+ return { unmount: () => app.unmount() };
47
+ };
5
48
 
6
49
  void startBrowserApp({
7
50
  bootstrap,
8
- mount(target: HTMLElement, { framework }: { framework: Framework }) {
9
- const state = reactive<{ page: BasePage | null; loading: boolean }>({
10
- page: null,
11
- loading: false,
12
- });
13
- const handleAction = (action: Action) => {
14
- void framework.perform(action);
15
- };
16
- createApp(App, { state, onAction: handleAction }).mount(target);
17
-
18
- return ({
19
- page,
20
- isFirstPage,
21
- }: {
22
- page: Promise<BasePage> | BasePage;
23
- isFirstPage?: boolean;
24
- }) => {
25
- if (page instanceof Promise) {
26
- if (!isFirstPage) state.loading = true;
27
- void page.then((p) => {
28
- state.page = p;
29
- state.loading = false;
30
- });
31
- } else {
32
- state.page = page;
33
- state.loading = false;
34
- }
35
- };
51
+ mount(target: HTMLElement, ctx) {
52
+ controller = ctx.app; // islands(mount 后挂)与 chrome 共用
53
+ const nav = ctx.navigation;
54
+ if (nav) {
55
+ state.snapshot = nav.getSnapshot(); // mount 时 tree 已就绪
56
+ nav.subscribe((s) => (state.snapshot = s));
57
+ }
58
+ // 方案 C:chrome 挂到 sibling chrome-root(不含 outlet);outlet 由框架编排器独占。
59
+ const { chromeRoot, hydrate } = resolveIslandsShell(target);
60
+ (hydrate ? createSSRApp : createApp)(App, { state, controller: markRaw(ctx.app!) }).mount(
61
+ chromeRoot,
62
+ );
63
+ return () => undefined;
36
64
  },
37
65
  callbacks: {
38
66
  onNavigate() {},
39
67
  onModal() {},
40
68
  },
69
+ // 结构化导航 + islands:每屏 per-entry 挂为独立 root、保活。
70
+ navigation: { ...navigation.toBrowserConfig(), mountEntry },
71
+ // 重载 DOM 自动恢复:data-restore-root 内字段/滚动自动捕获回填。
72
+ domRestore: true,
73
+ // 会话恢复:注册全局切片 provider;导航位置 + 作用域状态由框架自动捕获。
74
+ session: { providers: [profileProvider] },
41
75
  });
@@ -1,28 +1,50 @@
1
- import { createSSRRender, serializeServerData } from "@finesoft/front";
2
- import { createSSRApp } from "vue";
1
+ import {
2
+ createSSRNavigationRender,
3
+ renderIslandsHtml,
4
+ serializeServerData,
5
+ type ResolvedEntry,
6
+ } from "@finesoft/front";
7
+ import { createSSRApp, type Component } from "vue";
3
8
  import { renderToString } from "vue/server-renderer";
4
9
  import App from "./App.vue";
5
- import { bootstrap } from "./bootstrap";
10
+ import HomeView from "./views/HomeView.vue";
11
+ import DetailView from "./views/DetailView.vue";
12
+ import NotesView from "./views/NotesView.vue";
13
+ import { bootstrap, navigation } from "./bootstrap";
6
14
 
7
- export const render = createSSRRender({
15
+ const VIEWS: Record<string, Component> = { home: HomeView, detail: DetailView, notes: NotesView };
16
+
17
+ /**
18
+ * islands 架构 SSR(方案 C):
19
+ * - chrome(App.vue,header + tabbar)渲进 `<div data-fs-chrome>`。
20
+ * - 可见 island 内容由 `renderIslandsHtml` 渲进 sibling `<main data-fs-outlet>`,客户端按 key 收养水合。
21
+ *
22
+ * chrome 水合 props parity:SSR 必须用与客户端 hydrate 时**相同**的初始 state 渲 App,否则
23
+ * App.vue 的 `v-if="state"`(name label 等)server/client 不一致 → hydration mismatch。
24
+ * 客户端 hydrate 时 state = { snapshot: null, name: "" }(onNavigationReady / session 恢复都在
25
+ * 水合**之后**才填)。controller 仅用于事件处理器(`controller?.`),不影响渲染 DOM,SSR 可省。
26
+ */
27
+ export const render = createSSRNavigationRender({
8
28
  bootstrap,
9
29
  getErrorPage(status, message) {
10
- return {
11
- id: "error",
12
- pageType: "error",
13
- title: `Error ${status}`,
14
- description: message,
15
- };
30
+ return { id: "error", pageType: "error", title: `Error ${status}`, description: message };
16
31
  },
17
- async renderApp(page, _framework) {
18
- const app = createSSRApp(App, { page });
19
- const html = await renderToString(app);
32
+ async renderApp(page, _framework, snapshot) {
33
+ // 客户端 mount 时 navigation.getSnapshot() 即此 URL 推导 snapshot(tree 一致)→ chrome 水合无失配,
34
+ // nav bar 首屏即被 SSR 渲出。name 仍默认 ""(会话恢复在 mount 后,水合后才生效)。
35
+ const chromeHtml = await renderToString(
36
+ createSSRApp(App, { state: { snapshot, name: "" } }),
37
+ );
38
+ const islandsHtml = await renderIslandsHtml(snapshot, (entry: ResolvedEntry) =>
39
+ renderToString(createSSRApp(VIEWS[entry.intent] ?? HomeView, { page: entry.page })),
40
+ );
20
41
  return {
21
- html,
42
+ html: `<div data-fs-chrome>${chromeHtml}</div><main data-fs-outlet>${islandsHtml}</main>`,
22
43
  head: `<title>${page.title}</title>`,
23
44
  css: "",
24
45
  };
25
46
  },
47
+ navigation: navigation.toSSRDefinition(),
26
48
  });
27
49
 
28
50
  export { serializeServerData };
@@ -0,0 +1,29 @@
1
+ <script setup lang="ts">
2
+ import type { BasePage } from "@finesoft/front";
3
+
4
+ defineProps<{ page: BasePage }>();
5
+ </script>
6
+
7
+ <template>
8
+ <section>
9
+ <h1 style="margin: 0 0 0.25rem">{{ page.title }}</h1>
10
+ <p style="color: #666; margin: 0 0 1rem">{{ page.description }}</p>
11
+
12
+ <!--
13
+ 零样板保活:标一个 data-restore-root,里面的裸 <input> 即自动:
14
+ - in-session:push 走、pop 回来值还在(islands 保活,实例没销毁)
15
+ - 重载:sessionStorage 回填(domRestore),合成事件驱动可能的受控绑定
16
+ 对比重构前:需手写 entryKey + watch + getScoped/setScoped —— 现在一行不写。
17
+ -->
18
+ <div data-restore-root>
19
+ <label style="display: block; margin-top: 1rem">
20
+ Draft note for this screen:
21
+ <input
22
+ name="note"
23
+ placeholder="kept while alive; restored on reload"
24
+ style="width: 100%"
25
+ />
26
+ </label>
27
+ </div>
28
+ </section>
29
+ </template>
@@ -0,0 +1,26 @@
1
+ <script setup lang="ts">
2
+ import { computed } from "vue";
3
+ import type { BasePage } from "@finesoft/front";
4
+ import type { FeedPage } from "../lib/controllers/home";
5
+ import type { AppController } from "../main";
6
+
7
+ const { page, controller } = defineProps<{ page: BasePage; controller?: AppController }>();
8
+ const feed = computed(() => (page.pageType === "home" ? (page as FeedPage) : null));
9
+ </script>
10
+
11
+ <template>
12
+ <section>
13
+ <h1 style="margin: 0 0 0.25rem">{{ page.title }}</h1>
14
+ <p style="color: #666; margin: 0 0 1rem">{{ page.description }}</p>
15
+ <ul v-if="feed" style="list-style: none; padding: 0; display: grid; gap: 0.5rem">
16
+ <li v-for="item in feed.items" :key="item.id">
17
+ <button
18
+ style="width: 100%; text-align: left"
19
+ @click="controller?.push('detail', { id: item.id })"
20
+ >
21
+ {{ item.title }} →
22
+ </button>
23
+ </li>
24
+ </ul>
25
+ </section>
26
+ </template>
@@ -0,0 +1,12 @@
1
+ <script setup lang="ts">
2
+ import type { BasePage } from "@finesoft/front";
3
+
4
+ defineProps<{ page: BasePage }>();
5
+ </script>
6
+
7
+ <template>
8
+ <section>
9
+ <h1 style="margin: 0 0 0.25rem">{{ page.title }}</h1>
10
+ <p style="color: #666; margin: 0 0 1rem">{{ page.description }}</p>
11
+ </section>
12
+ </template>
@@ -1,12 +0,0 @@
1
- <script setup lang="ts">
2
- import type { BasePage } from "@finesoft/front";
3
-
4
- defineProps<{ page: BasePage }>();
5
- </script>
6
-
7
- <template>
8
- <div>
9
- <h1>{{ page.title }}</h1>
10
- <p>{{ page.description }}</p>
11
- </div>
12
- </template>