diff --git a/core/player/src/controllers/flags/__tests__/index.test.ts b/core/player/src/controllers/flags/__tests__/index.test.ts new file mode 100644 index 000000000..c8524699e --- /dev/null +++ b/core/player/src/controllers/flags/__tests__/index.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from "vitest"; +import { FlagController } from "../controller"; +import { PlayerFlags } from "../types"; + +describe("Controller Functionality", () => { + const mockFlags: PlayerFlags = { + duplicateIDLogLevel: "error", + }; + + it("Basic Functionality", () => { + const controller = new FlagController(mockFlags); + + expect(controller.getFlag("duplicateIDLogLevel")).toBe("error"); + + controller.updateFlags({ duplicateIDLogLevel: "debug" }); + + expect(controller.getFlag("duplicateIDLogLevel")).toBe("debug"); + }); + + it("Hooks", () => { + const controller = new FlagController(mockFlags); + + controller.hooks.overrideFlag.tap("test", (value, key) => { + expect(value).toBe("error"); + expect(key).toBe("duplicateIDLogLevel"); + + return "warning"; + }); + + expect(controller.getFlag("duplicateIDLogLevel")).toBe("warning"); + }); +}); diff --git a/core/player/src/controllers/flags/controller.ts b/core/player/src/controllers/flags/controller.ts new file mode 100644 index 000000000..429c02652 --- /dev/null +++ b/core/player/src/controllers/flags/controller.ts @@ -0,0 +1,32 @@ +import dlv from "dlv"; + +import { PlayerFlags, Flag, DefaultFlags } from "./types"; +import { SyncWaterfallHook } from "tapable-ts"; + +export class FlagController { + private flags: PlayerFlags; + + constructor(flags?: PlayerFlags) { + this.flags = { ...DefaultFlags, ...flags }; + } + + /** Hooks for the FlagsController */ + public readonly hooks: { + /** Allow a plugin or integration to dynamically change a flag without setting it globally */ + overrideFlag: SyncWaterfallHook<[any, string]>; + } = { + overrideFlag: new SyncWaterfallHook(), + }; + + public updateFlags(newFlags: Partial): void { + this.flags = { + ...this.flags, + ...newFlags, + }; + } + + public getFlag(flag: Flag): T { + const configuredFlag = dlv(this.flags, flag) as PlayerFlags[Flag]; + return this.hooks.overrideFlag.call(configuredFlag, flag); + } +} diff --git a/core/player/src/controllers/flags/index.ts b/core/player/src/controllers/flags/index.ts new file mode 100644 index 000000000..000fae134 --- /dev/null +++ b/core/player/src/controllers/flags/index.ts @@ -0,0 +1,2 @@ +export * from "./controller"; +export * from "./types"; diff --git a/core/player/src/controllers/flags/types.ts b/core/player/src/controllers/flags/types.ts new file mode 100644 index 000000000..4782d2085 --- /dev/null +++ b/core/player/src/controllers/flags/types.ts @@ -0,0 +1,17 @@ +import { Severity } from "../../logger"; + +/** Configuration for runtime Player behavior */ +export interface PlayerFlags { + /** What log level duplicate ID errors during view resolution should be raised as */ + duplicateIDLogLevel: Severity; + + /** Log level for when there are cache conflicts in view resolution (usually related to duplicate IDs) */ + cacheConflictLogLevel: Severity; +} + +export const DefaultFlags: PlayerFlags = { + duplicateIDLogLevel: "error", + cacheConflictLogLevel: "info", +}; + +export type Flag = keyof PlayerFlags; diff --git a/core/player/src/controllers/index.ts b/core/player/src/controllers/index.ts index 9064b28e3..69cdc270c 100644 --- a/core/player/src/controllers/index.ts +++ b/core/player/src/controllers/index.ts @@ -3,3 +3,4 @@ export * from "./validation"; export * from "./view"; export * from "./data/controller"; export * from "./constants"; +export * from "./flags"; diff --git a/core/player/src/controllers/view/controller.ts b/core/player/src/controllers/view/controller.ts index 373f0894c..f1251238e 100644 --- a/core/player/src/controllers/view/controller.ts +++ b/core/player/src/controllers/view/controller.ts @@ -12,6 +12,7 @@ import type { DataController } from "../data/controller"; import { AssetTransformCorePlugin } from "./asset-transform"; import type { TransformRegistry } from "./types"; import type { BindingInstance } from "../../binding"; +import { FlagController } from "../flags"; export interface ViewControllerOptions { /** Where to get data from */ @@ -22,11 +23,22 @@ export interface ViewControllerOptions { /** A flow-controller instance to listen for view changes */ flowController: FlowController; + + /** A FlagController instance to */ + flagController: FlagController; } /** A controller to manage updating/switching views */ export class ViewController { - public readonly hooks = { + public readonly hooks: { + /** Do any processing before the `View` instance is created */ + resolveView: SyncWaterfallHook< + [View | undefined, string, NavigationFlowViewState], + Record + >; + // The hook right before the View starts resolving. Attach anything custom here + view: SyncHook<[ViewInstance], Record>; + } = { /** Do any processing before the `View` instance is created */ resolveView: new SyncWaterfallHook< [View | undefined, string, NavigationFlowViewState] @@ -156,7 +168,7 @@ export class ViewController { } } - public onView(state: NavigationFlowViewState) { + public onView(state: NavigationFlowViewState): void { const viewId = state.ref; const source = this.hooks.resolveView.call( diff --git a/core/player/src/player.ts b/core/player/src/player.ts index 1e8525b74..318b4df8d 100644 --- a/core/player/src/player.ts +++ b/core/player/src/player.ts @@ -11,13 +11,14 @@ import { SchemaController } from "./schema"; import { BindingParser } from "./binding"; import type { ViewInstance } from "./view"; import { resolveDataRefs } from "./string-resolver"; -import type { FlowInstance } from "./controllers"; +import type { FlowInstance, PlayerFlags } from "./controllers"; import { ConstantsController, ViewController, DataController, ValidationController, FlowController, + FlagController, } from "./controllers"; import { FlowExpPlugin } from "./plugins/flow-exp-plugin"; import { DefaultExpPlugin } from "./plugins/default-exp-plugin"; @@ -68,6 +69,9 @@ export interface PlayerConfigOptions { /** A logger to use */ logger?: Logger; + + /** Flags to configure runtime Player behavior */ + flags?: PlayerFlags; } export interface PlayerInfo { @@ -87,12 +91,39 @@ export class Player { commit: COMMIT, }; - public readonly logger = new TapableLogger(); - public readonly constantsController = new ConstantsController(); + public readonly logger: TapableLogger = new TapableLogger(); + public readonly constantsController: ConstantsController = + new ConstantsController(); + public readonly flagsController: FlagController; private config: PlayerConfigOptions; private state: PlayerFlowState = NOT_STARTED_STATE; - public readonly hooks = { + public readonly hooks: { + /** The hook that fires every time we create a new flowController (a new Content blob is passed in) */ + flowController: SyncHook<[FlowController], Record>; + /** The hook that updates/handles views */ + viewController: SyncHook<[ViewController], Record>; + /** A hook called every-time there's a new view. This is equivalent to the view hook on the view-controller */ + view: SyncHook<[ViewInstance], Record>; + /** Called when an expression evaluator was created */ + expressionEvaluator: SyncHook<[ExpressionEvaluator], Record>; + /** The hook that creates and manages data */ + dataController: SyncHook<[DataController], Record>; + /** Called after the schema is created for a flow */ + schema: SyncHook<[SchemaController], Record>; + /** Manages validations (schema and x-field ) */ + validationController: SyncHook<[ValidationController], Record>; + /** Manages parsing binding */ + bindingParser: SyncHook<[BindingParser], Record>; + /** A that's called for state changes in the flow execution */ + state: SyncHook<[PlayerFlowState], Record>; + /** A hook to access the current flow */ + onStart: SyncHook<[FlowType], Record>; + /** A hook for when the flow ends either in success or failure */ + onEnd: SyncHook<[], Record>; + /** Mutate the Content flow before starting */ + resolveFlowContent: SyncWaterfallHook<[FlowType], Record>; + } = { /** The hook that fires every time we create a new flowController (a new Content blob is passed in) */ flowController: new SyncHook<[FlowController]>(), @@ -125,6 +156,7 @@ export class Player { /** A hook for when the flow ends either in success or failure */ onEnd: new SyncHook<[]>(), + /** Mutate the Content flow before starting */ resolveFlowContent: new SyncWaterfallHook<[FlowType]>(), }; @@ -134,6 +166,8 @@ export class Player { this.logger.addHandler(config.logger); } + this.flagsController = new FlagController(config?.flags); + this.config = config || {}; this.config.plugins = [ new DefaultExpPlugin(), @@ -171,7 +205,7 @@ export class Player { } /** Register and apply [Plugin] if one with the same symbol is not already registered. */ - public registerPlugin(plugin: PlayerPlugin) { + public registerPlugin(plugin: PlayerPlugin): void { plugin.apply(this); this.config.plugins?.push(plugin); } @@ -419,6 +453,7 @@ export class Player { type: (b) => schema.getType(parseBinding(b)), }, constants: this.constantsController, + flagController: this.flagsController, }); viewController.hooks.view.tap("player", (view) => { validationController.onView(view); diff --git a/core/player/src/view/resolver/__tests__/edgecases.test.ts b/core/player/src/view/resolver/__tests__/edgecases.test.ts index 5b8e5f3a9..b99ac24d3 100644 --- a/core/player/src/view/resolver/__tests__/edgecases.test.ts +++ b/core/player/src/view/resolver/__tests__/edgecases.test.ts @@ -1,10 +1,10 @@ -import { describe, it, expect, vitest } from "vitest"; +import { describe, it, expect, vitest, MockedFunction } from "vitest"; import { replaceAt, set, omit } from "timm"; import { BindingParser } from "../../../binding"; import { ExpressionEvaluator } from "../../../expressions"; import { LocalModel, withParser } from "../../../data"; import { SchemaController } from "../../../schema"; -import type { Logger } from "../../../logger"; +import type { Logger, LogFn } from "../../../logger"; import { TapableLogger } from "../../../logger"; import { Resolver } from ".."; import type { Node } from "../../parser"; @@ -14,6 +14,7 @@ import { MultiNodePlugin, AssetPlugin, } from "../../plugins"; +import { FlagController } from "../../../controllers"; describe("Dynamic AST Transforms", () => { const content = { @@ -58,6 +59,7 @@ describe("Dynamic AST Transforms", () => { model: withParser(model, bindingParser.parse), }), schema: new SchemaController(), + flagController: new FlagController(), }); // basic transform to change the asset @@ -168,6 +170,7 @@ describe("Dynamic AST Transforms", () => { model: withParser(model, bindingParser.parse), }), schema: new SchemaController(), + flagController: new FlagController(), }); resolver.update(); @@ -237,6 +240,7 @@ describe("Dynamic AST Transforms", () => { model: withParser(model, bindingParser.parse), }), schema: new SchemaController(), + flagController: new FlagController(), }); let inputNode: Node.Node | undefined; @@ -290,6 +294,7 @@ describe("Dynamic AST Transforms", () => { model: withParser(model, bindingParser.parse), }), schema: new SchemaController(), + flagController: new FlagController(), }); let parent; @@ -387,6 +392,7 @@ describe("Duplicate IDs", () => { }), schema: new SchemaController(), logger, + flagController: new FlagController(), }); new StringResolverPlugin().applyResolver(resolver); @@ -397,7 +403,7 @@ describe("Duplicate IDs", () => { expect(testLogger.error).toBeCalledWith( "Cache conflict: Found Asset/View nodes that have conflicting ids: action-1, may cause cache issues.", ); - (testLogger.error as jest.Mock).mockClear(); + (testLogger.error as MockedFunction).mockClear(); expect(firstUpdate).toStrictEqual({ id: "action", @@ -484,6 +490,7 @@ describe("Duplicate IDs", () => { }), schema: new SchemaController(), logger, + flagController: new FlagController(), }); new StringResolverPlugin().applyResolver(resolver); @@ -494,7 +501,7 @@ describe("Duplicate IDs", () => { expect(testLogger.info).toBeCalledWith( "Cache conflict: Found Value nodes that have conflicting ids: value-1, may cause cache issues. To improve performance make value node IDs globally unique.", ); - (testLogger.info as jest.Mock).mockClear(); + (testLogger.info as MockedFunction).mockClear(); expect(firstUpdate).toStrictEqual(content); resolver.update(); @@ -535,6 +542,7 @@ describe("AST caching", () => { model: withParser(model, bindingParser.parse), }), schema: new SchemaController(), + flagController: new FlagController(), }); const resolvedNodes: any[] = []; @@ -602,6 +610,7 @@ describe("Root AST Immutability", () => { model: withParser(model, bindingParser.parse), }), schema: new SchemaController(), + flagController: new FlagController(), }); let finalNode; diff --git a/core/player/src/view/resolver/index.ts b/core/player/src/view/resolver/index.ts index 12e59977c..e3275ded2 100644 --- a/core/player/src/view/resolver/index.ts +++ b/core/player/src/view/resolver/index.ts @@ -9,12 +9,13 @@ import type { Updates, } from "../../data"; import { DependencyModel, withParser } from "../../data"; -import type { Logger } from "../../logger"; +import type { Logger, Severity } from "../../logger"; import type { Node } from "../parser"; import { NodeType } from "../parser"; import { caresAboutDataChanges, toNodeResolveOptions } from "./utils"; import type { Resolve } from "./types"; import { getNodeID } from "../parser/utils"; +import { FlagController } from "../../controllers"; export * from "./types"; export * from "./utils"; @@ -58,7 +59,51 @@ const withContext = (model: DataModelWithParser): DataModelWithParser => { * It combines the ability to mutate ast nodes before resolving, as well as the mutating the resolved objects while parsing */ export class Resolver { - public readonly hooks = { + public readonly hooks: { + /** A hook to allow skipping of the resolution tree for a specific node */ + skipResolve: SyncWaterfallHook< + [boolean, Node.Node, Resolve.NodeResolveOptions], + Record + >; + /** An event emitted before calculating the next update */ + beforeUpdate: SyncHook< + [Set | undefined], + Record + >; + /** An event emitted after calculating the next update */ + afterUpdate: SyncHook<[any], Record>; + /** The options passed to a node to resolve it to an object */ + resolveOptions: SyncWaterfallHook< + [Resolve.NodeResolveOptions, Node.Node], + Record + >; + /** A hook to transform the AST node into a new AST node before resolving it */ + beforeResolve: SyncWaterfallHook< + [Node.Node | null, Resolve.NodeResolveOptions], + Record + >; + /** + * A hook to transform an AST node into it's resolved value. + * This runs _before_ any children are resolved + */ + resolve: SyncWaterfallHook< + [any, Node.Node, Resolve.NodeResolveOptions], + Record + >; + /** + * A hook to transform the resolved value of an AST node. + * This runs _after_ all children nodes are resolved + */ + afterResolve: SyncWaterfallHook< + [any, Node.Node, Resolve.NodeResolveOptions], + Record + >; + /** Called at the very end of a node's tree being updated */ + afterNodeUpdate: SyncHook< + [Node.Node, Node.Node | undefined, NodeUpdate], + Record + >; + } = { /** A hook to allow skipping of the resolution tree for a specific node */ skipResolve: new SyncWaterfallHook< [boolean, Node.Node, Resolve.NodeResolveOptions] @@ -141,7 +186,7 @@ export class Resolver { this.idCache = new Set(); } - public getSourceNode(convertedAST: Node.Node) { + public getSourceNode(convertedAST: Node.Node): Node.Node | undefined { return this.ASTMap.get(convertedAST); } @@ -167,10 +212,28 @@ export class Resolver { return updated.value; } - public getResolveCache() { + public getResolveCache(): Map { return new Map(this.resolveCache); } + /** Use flags to determine how to log the message */ + private logCacheIssue(id: string, type: NodeType): void { + let message: string | undefined; + let loglevel: Severity | undefined; + + if (type === NodeType.Asset || type === NodeType.View) { + loglevel = this.options.flagController.getFlag("duplicateIDLogLevel"); + message = `Cache conflict: Found Asset/View nodes that have conflicting ids: ${id}, may cause cache issues.`; + } else if (type === NodeType.Value) { + loglevel = this.options.flagController.getFlag("cacheConflictLogLevel"); + message = `Cache conflict: Found Value nodes that have conflicting ids: ${id}, may cause cache issues. To improve performance make value node IDs globally unique.`; + } + + if (message && loglevel) { + this.logger?.[loglevel]?.(message); + } + } + private getPreviousResult(node: Node.Node): Resolve.ResolvedNode | undefined { if (!node) { return; @@ -184,15 +247,7 @@ export class Resolver { // Only log this conflict once to cut down on noise // May want to swap this to logging when we first see the id -- which may not be the first render if (isFirstUpdate) { - if (node.type === NodeType.Asset || node.type === NodeType.View) { - this.logger?.error( - `Cache conflict: Found Asset/View nodes that have conflicting ids: ${id}, may cause cache issues.`, - ); - } else if (node.type === NodeType.Value) { - this.logger?.info( - `Cache conflict: Found Value nodes that have conflicting ids: ${id}, may cause cache issues. To improve performance make value node IDs globally unique.`, - ); - } + this.logCacheIssue(id, node.type); } // Don't use anything from a prev result if there's a duplicate id detected diff --git a/core/player/src/view/resolver/types.ts b/core/player/src/view/resolver/types.ts index 69d225b15..5559554dc 100644 --- a/core/player/src/view/resolver/types.ts +++ b/core/player/src/view/resolver/types.ts @@ -14,7 +14,7 @@ import type { DataModelOptions, } from "../../data"; import type { ConstantsProvider } from "../../controllers/constants"; -import type { TransitionFunction } from "../../controllers"; +import type { FlagController, TransitionFunction } from "../../controllers"; import type { ExpressionEvaluator, ExpressionType } from "../../expressions"; import type { ValidationResponse } from "../../validator"; import type { Logger } from "../../logger"; @@ -98,6 +98,9 @@ export declare namespace Resolve { /** A logger to use */ logger?: Logger; + /** Used for getting runtime configuration */ + flagController: FlagController; + /** Utils for various useful operations */ utils?: PlayerUtils; diff --git a/core/player/src/view/view.ts b/core/player/src/view/view.ts index 8479e568f..73f715647 100644 --- a/core/player/src/view/view.ts +++ b/core/player/src/view/view.ts @@ -74,7 +74,13 @@ class CrossfieldProvider implements ValidationProvider { /** A stateful view instance from an content */ export class ViewInstance implements ValidationProvider { - public hooks = { + public hooks: { + onUpdate: SyncHook<[ViewType], Record>; + parser: SyncHook<[Parser], Record>; + resolver: SyncHook<[Resolver], Record>; + onTemplatePluginCreated: SyncHook<[TemplatePlugin], Record>; + templatePlugin: SyncHook<[TemplatePlugin], Record>; + } = { onUpdate: new SyncHook<[ViewType]>(), parser: new SyncHook<[Parser]>(), resolver: new SyncHook<[Resolver]>(), @@ -102,13 +108,13 @@ export class ViewInstance implements ValidationProvider { }); } - public updateAsync() { + public updateAsync(): void { const update = this.resolver?.update(); this.lastUpdate = update; this.hooks.onUpdate.call(update); } - public update(changes?: Set) { + public update(changes?: Set): any { if (this.rootNode === undefined) { /** On initialization of the view, also create a validation parser */ this.validationProvider = new CrossfieldProvider( @@ -148,7 +154,9 @@ export class ViewInstance implements ValidationProvider { return update; } - getValidationsForBinding(binding: BindingInstance) { + getValidationsForBinding( + binding: BindingInstance, + ): ValidationObject[] | undefined { return this.validationProvider?.getValidationsForBinding(binding); } }