diff --git a/packages/runtime/src/internal/Router.ts b/packages/runtime/src/internal/Router.ts index 33c624c379..e1f61fda8f 100644 --- a/packages/runtime/src/internal/Router.ts +++ b/packages/runtime/src/internal/Router.ts @@ -85,6 +85,7 @@ export class Router { #navConfig?: { breadcrumb?: BreadcrumbItemConf[]; }; + #bootstrapFailed = false; constructor(storyboards: Storyboard[]) { this.#storyboards = storyboards; @@ -231,7 +232,12 @@ export class Router { ignoreRendering = true; } - if (!ignoreRendering && !location.state?.noIncremental) { + // Note: dot not perform incremental render when bootstrap failed. + if ( + !ignoreRendering && + !location.state?.noIncremental && + !this.#bootstrapFailed + ) { ignoreRendering = await this.#rendererContext?.didPerformIncrementalRender( location, @@ -491,6 +497,9 @@ export class Router { } catch (error) { // eslint-disable-next-line no-console console.error("Router failed:", error); + if (isBootstrap) { + this.#bootstrapFailed = true; + } const result = await routeHelper.catch(error, renderRoot, isBootstrap); if (!result) { return; @@ -498,6 +507,7 @@ export class Router { ({ failed, output } = result); } renderRoot.child = output.node; + this.#bootstrapFailed = false; cleanUpPreviousRender(); diff --git a/packages/runtime/src/internal/Runtime.spec.ts b/packages/runtime/src/internal/Runtime.spec.ts index d2d1f2e8f0..289a74f031 100644 --- a/packages/runtime/src/internal/Runtime.spec.ts +++ b/packages/runtime/src/internal/Runtime.spec.ts @@ -460,6 +460,62 @@ const getBootstrapData = (options?: { }, ], }, + { + path: "${APP.homepage}/sub-routes-with-error", + incrementalSubRoutes: true, + type: "bricks", + bricks: [ + { + brick: "h1", + properties: { + textContent: "Sub-routes with error", + }, + }, + { + brick: "div", + slots: { + "": { + type: "routes", + routes: [ + { + path: "${APP.homepage}/sub-routes-with-error/ok", + type: "bricks", + bricks: [ + { + brick: "p", + properties: { + textContent: "OK", + }, + }, + ], + }, + { + path: "${APP.homepage}/sub-routes-with-error/fail", + context: [ + { + name: "myFailedData", + resolve: { + useProvider: "my-timeout-provider", + args: [0, "oops", true], + }, + }, + ], + type: "bricks", + bricks: [ + { + brick: "p", + properties: { + textContent: "<% CTX.myFailedData %>", + }, + }, + ], + }, + ], + }, + }, + }, + ], + }, { path: "${APP.homepage}/block", exact: true, @@ -589,9 +645,9 @@ const getBootstrapData = (options?: { }); const myTimeoutProvider = jest.fn( - (timeout: number, result?: unknown) => - new Promise((resolve) => { - setTimeout(() => resolve(result), timeout); + (timeout: number, result?: unknown, fail?: boolean) => + new Promise((resolve, reject) => { + setTimeout(() => (fail ? reject : resolve)(result), timeout); }) ); customElements.define( @@ -844,7 +900,7 @@ describe("Runtime", () => { }); }); - test("incremental sub-router rendering", async () => { + test("incremental sub-routes rendering", async () => { createRuntime().initialize(getBootstrapData()); getHistory().push("/app-a/sub-routes/1"); await getRuntime().bootstrap(); @@ -1401,6 +1457,118 @@ describe("Runtime", () => { }); }); + test("incremental sub-routes with error", async () => { + consoleError.mockReturnValueOnce(); + createRuntime().initialize(getBootstrapData()); + getHistory().push("/app-a/sub-routes-with-error/ok"); + await getRuntime().bootstrap(); + await (global as any).flushPromises(); + expect(document.body.children).toMatchInlineSnapshot(` + HTMLCollection [ +
+

+ Sub-routes with error +

+
+

+ OK +

+
+
, +
, + ] + `); + expect(consoleError).toHaveBeenCalledTimes(0); + + getHistory().push("/app-a/sub-routes-with-error/fail"); + await (global as any).flushPromises(); + await new Promise((resolve) => setTimeout(resolve)); + expect(document.body.children).toMatchInlineSnapshot(` + HTMLCollection [ +
+

+ Sub-routes with error +

+
+
+
+ Oops! Something went wrong: oops +
+
+
+
, +
, + ] + `); + expect(consoleError).toHaveBeenCalledTimes(1); + + getHistory().push("/app-a/sub-routes-with-error/ok"); + await (global as any).flushPromises(); + expect(document.body.children).toMatchInlineSnapshot(` + HTMLCollection [ +
+

+ Sub-routes with error +

+
+

+ OK +

+
+
, +
, + ] + `); + expect(consoleError).toHaveBeenCalledTimes(1); + }); + + test("bootstrap error should prevent incremental render", async () => { + consoleError.mockReturnValueOnce(); + createRuntime().initialize(getBootstrapData()); + getHistory().push("/app-a/sub-routes-with-error/fail"); + await expect(() => getRuntime().bootstrap()).rejects.toMatchInlineSnapshot( + `"oops"` + ); + expect(consoleError).toHaveBeenCalledTimes(1); + + getHistory().push("/app-a/sub-routes-with-error/ok"); + await (global as any).flushPromises(); + expect(document.body.children).toMatchInlineSnapshot(` + HTMLCollection [ +
+

+ Sub-routes with error +

+
+

+ OK +

+
+
, +
, + ] + `); + expect(consoleError).toHaveBeenCalledTimes(1); + }); + test("abstract routes rendering", async () => { createRuntime().initialize(getBootstrapData()); getHistory().push("/app-a/abstract-routes/1"); diff --git a/packages/runtime/src/internal/data/DataStore.ts b/packages/runtime/src/internal/data/DataStore.ts index 9aeafcc276..83fed5639d 100644 --- a/packages/runtime/src/internal/data/DataStore.ts +++ b/packages/runtime/src/internal/data/DataStore.ts @@ -8,6 +8,7 @@ import type { import { hasOwnProperty, isObject } from "@next-core/utils/general"; import { strictCollectMemberUsage } from "@next-core/utils/storyboard"; import EventTarget from "@ungap/event-target"; +import { pull } from "lodash"; import { eventCallbackFactory, listenerFactory } from "../bindListeners.js"; import { asyncCheckIf, checkIf } from "../compute/checkIf.js"; import { @@ -55,18 +56,20 @@ export interface DataStoreItem { deps: string[]; } +type PendingStackItem = ReturnType; + export class DataStore { private readonly type: T; private readonly data = new Map(); private readonly changeEventType: string; - private readonly pendingStack: Array> = - []; + private readonly pendingStack: Array = []; public readonly hostBrick?: RuntimeBrick; private readonly stateStoreId?: string; private batchUpdate = false; private batchUpdateContextsNames: string[] = []; private readonly rendererContext?: RendererContext; private routeMap = new WeakMap>(); + private routeStackMap = new WeakMap>(); // 把 `rendererContext` 放在参数列表的最后,并作为可选,以减少测试文件的调整 constructor( @@ -325,6 +328,16 @@ export class DataStore { this.type, isStrictMode(runtimeContext) ); + if (Array.isArray(routePath)) { + for (const route of routePath) { + const stack = this.routeStackMap.get(route); + if (stack) { + stack.add(pending); + } else { + this.routeStackMap.set(route, new Set([pending])); + } + } + } this.pendingStack.push(pending); } } @@ -349,14 +362,6 @@ export class DataStore { } async waitForAll(): Promise { - // Silent each pending contexts, since the error is handled by batched `pendingResult` - for (const { pendingContexts } of this.pendingStack) { - for (const p of pendingContexts.values()) { - p.catch(() => { - /* No-op */ - }); - } - } for (const { pendingResult } of this.pendingStack) { await pendingResult; } @@ -556,13 +561,24 @@ export class DataStore { return true; } + /** + * For sub-routes to be incrementally rendered, + * dispose their data and pending tasks. + */ disposeDataInRoutes(routes: RouteConf[]) { + // for (const route of routes) { const names = this.routeMap.get(route); if (names !== undefined) { for (const name of names) { this.data.delete(name); } + this.routeMap.delete(route); + } + const stack = this.routeStackMap.get(route); + if (stack !== undefined) { + pull(this.pendingStack, ...stack); + this.routeStackMap.delete(route); } } } diff --git a/packages/runtime/src/internal/data/resolveDataStore.ts b/packages/runtime/src/internal/data/resolveDataStore.ts index 573b2f3822..5a372dc497 100644 --- a/packages/runtime/src/internal/data/resolveDataStore.ts +++ b/packages/runtime/src/internal/data/resolveDataStore.ts @@ -36,12 +36,14 @@ export function resolveDataStore( const deferredContexts = new Map(); const pendingContexts = new Map( [...new Set(contextConfs.map((contextConf) => contextConf.name))].map( - (contextName) => [ - contextName, - new Promise((resolve, reject) => { + (contextName) => { + const promise = new Promise((resolve, reject) => { deferredContexts.set(contextName, { resolve, reject }); - }), - ] + }); + // The pending context will be caught by the renderer. + promise.catch(() => {}); + return [contextName, promise]; + } ) ); @@ -110,6 +112,8 @@ export function resolveDataStore( } throw error; }); + // The pending result will be caught by the renderer. + pendingResult.catch(() => {}); return { pendingResult, pendingContexts }; }