From caee64614108102708e6c4e4499d85b9823a2638 Mon Sep 17 00:00:00 2001 From: Vlad Date: Mon, 20 Jul 2026 15:48:22 +0300 Subject: [PATCH] Add Component is Loaded trigger node for workflows --- .../component-scope-trigger-routing.test.ts | 130 +++++++++++++ .../page-loaded-scoping-skip.test.ts | 97 ++++++++++ .../__tests__/route-changed-trigger.test.ts | 106 +++++++++++ .../state-update-missing-setter-warn.test.ts | 180 ++++++++++++++++++ .../src/trigger-generator.ts | 35 ++++ .../src/workflow-component-plugin.ts | 73 ++++++- .../src/workflow-project-plugin.ts | 44 ++++- packages/teleport-shared/src/utils/generic.ts | 6 +- packages/teleport-types/src/uidl.ts | 2 +- scripts/watcher.mjs | 2 +- 10 files changed, 669 insertions(+), 6 deletions(-) create mode 100644 packages/teleport-plugin-next-workflows/__tests__/component-scope-trigger-routing.test.ts create mode 100644 packages/teleport-plugin-next-workflows/__tests__/page-loaded-scoping-skip.test.ts create mode 100644 packages/teleport-plugin-next-workflows/__tests__/route-changed-trigger.test.ts create mode 100644 packages/teleport-plugin-next-workflows/__tests__/state-update-missing-setter-warn.test.ts diff --git a/packages/teleport-plugin-next-workflows/__tests__/component-scope-trigger-routing.test.ts b/packages/teleport-plugin-next-workflows/__tests__/component-scope-trigger-routing.test.ts new file mode 100644 index 000000000..4a92b5326 --- /dev/null +++ b/packages/teleport-plugin-next-workflows/__tests__/component-scope-trigger-routing.test.ts @@ -0,0 +1,130 @@ +// Routing guard for the `'component'` trigger scope: an `event-component-loaded` +// workflow must be generated ONLY into the component it is bound to (matched by +// the emitted component name, with a normalized-compare and componentId +// fallback) — never into pages, never into unrelated components. This is the +// fix for the original bug where a component workflow authored with page scope +// was stripped from the component and silently fanned out to every page. + +import { createNextWorkflowPlugin } from '../src/workflow-component-plugin' + +const componentLoadedWorkflow = (config: Record) => ({ + id: 'wf-comp-loaded', + name: 'Auto-open Sidebar Group', + trigger: { + type: 'event-component-loaded', + nodeId: 'trigger-component-loaded', + scope: 'component', + config, + }, + nodes: [ + { + id: 'update-1', + type: 'state-update-local-state', + config: { property: 'sidebarGroupOpen', value: 'guilds' }, + stepNumber: 1, + label: 'Set group', + }, + ], + edges: [{ id: 'e1', source: 'trigger-component-loaded', target: 'update-1' }], +}) + +const jsxComponentChunk = () => ({ + type: 'chunk-type-ast', + name: 'jsx-component', + content: { + type: 'VariableDeclaration', + declarations: [ + { + type: 'VariableDeclarator', + init: { + type: 'ArrowFunctionExpression', + body: { + type: 'BlockStatement', + body: [{ type: 'ReturnStatement', argument: null }], + }, + }, + }, + ], + }, +}) + +const buildStructure = (uidl: Record, workflow: any): any => ({ + uidl: { + node: { type: 'element', content: { elementType: 'container', name: 'Container' } }, + stateDefinitions: { sidebarGroupOpen: { type: 'string', defaultValue: '' } }, + ...uidl, + }, + chunks: [jsxComponentChunk()], + options: { + workflows: { workflows: { [workflow.id]: workflow }, customNodes: {} }, + }, + dependencies: {}, +}) + +const getWorkflowModule = async ( + uidl: Record, + workflow: any, + isPage: boolean +): Promise => { + const plugin = createNextWorkflowPlugin({ isPage }) + const structure = buildStructure(uidl, workflow) + await plugin(structure as any) + const moduleChunk = (structure.chunks as any[]).find((c: any) => c.name === 'workflow-module') + return moduleChunk ? String(moduleChunk.content) : null +} + +describe('component-scope trigger routing', () => { + it('generates the workflow into the component matching componentName', async () => { + const code = await getWorkflowModule( + { name: 'Navigation' }, + componentLoadedWorkflow({ componentId: 'TQ_QmitTH632i', componentName: 'Navigation' }), + false + ) + expect(code).not.toBeNull() + expect(code).toContain('Component loaded') + expect(code).toContain("componentName: 'Navigation'") + }) + + it('matches case/separator-insensitively (generator may re-case the name)', async () => { + const code = await getWorkflowModule( + { name: 'CookieConsent' }, + componentLoadedWorkflow({ componentName: 'Cookie Consent' }), + false + ) + expect(code).not.toBeNull() + expect(code).toContain('Component loaded') + }) + + it('falls back to componentId when the name does not match', async () => { + const code = await getWorkflowModule( + { name: 'Navigation', outputOptions: { fileName: 'navigation' } }, + componentLoadedWorkflow({ componentId: 'navigation', componentName: 'Renamed Later' }), + false + ) + expect(code).not.toBeNull() + expect(code).toContain('Component loaded') + }) + + it('does NOT generate into an unrelated component', async () => { + const code = await getWorkflowModule( + { name: 'Footer' }, + componentLoadedWorkflow({ componentId: 'TQ_QmitTH632i', componentName: 'Navigation' }), + false + ) + expect(code).toBeNull() + }) + + it('does NOT generate into pages', async () => { + const code = await getWorkflowModule( + { name: 'Navigation', outputOptions: { pageId: 'page-1', fileName: 'home' } }, + componentLoadedWorkflow({ componentId: 'TQ_QmitTH632i', componentName: 'Navigation' }), + true + ) + expect(code).toBeNull() + }) + + it('matches nothing when both componentId and componentName are missing', async () => { + const code = await getWorkflowModule({ name: 'Navigation' }, componentLoadedWorkflow({}), false) + expect(code).toBeNull() + }) +}) diff --git a/packages/teleport-plugin-next-workflows/__tests__/page-loaded-scoping-skip.test.ts b/packages/teleport-plugin-next-workflows/__tests__/page-loaded-scoping-skip.test.ts new file mode 100644 index 000000000..00ee75cfa --- /dev/null +++ b/packages/teleport-plugin-next-workflows/__tests__/page-loaded-scoping-skip.test.ts @@ -0,0 +1,97 @@ +// `event-page-loaded` scoping guard: a page-loaded workflow with NO page +// scoping (no pageId, no selectedPages, no allPages) must be SKIPPED, not +// injected into every page. Silent all-pages fan-out masked mis-scoped +// workflows whose state target lives in a component (the setter no-ops on +// pages). Explicit `allPages: true` is the supported way to run everywhere. + +import { createNextWorkflowPlugin } from '../src/workflow-component-plugin' + +const pageLoadedWorkflow = (config: Record) => ({ + id: 'wf-page-loaded', + name: 'Page Init', + trigger: { + type: 'event-page-loaded', + nodeId: 'trigger-page-loaded', + scope: 'page', + config, + }, + nodes: [ + { + id: 'update-1', + type: 'state-update-local-state', + config: { property: 'ready', value: 'true' }, + stepNumber: 1, + label: 'Set ready', + }, + ], + edges: [{ id: 'e1', source: 'trigger-page-loaded', target: 'update-1' }], +}) + +const jsxComponentChunk = () => ({ + type: 'chunk-type-ast', + name: 'jsx-component', + content: { + type: 'VariableDeclaration', + declarations: [ + { + type: 'VariableDeclarator', + init: { + type: 'ArrowFunctionExpression', + body: { + type: 'BlockStatement', + body: [{ type: 'ReturnStatement', argument: null }], + }, + }, + }, + ], + }, +}) + +const getWorkflowModule = async (workflow: any, pageId: string): Promise => { + const plugin = createNextWorkflowPlugin({ isPage: true }) + const structure: any = { + uidl: { + name: 'Page', + outputOptions: { pageId, fileName: pageId }, + node: { type: 'element', content: { elementType: 'container', name: 'Container' } }, + stateDefinitions: { ready: { type: 'string', defaultValue: '' } }, + }, + chunks: [jsxComponentChunk()], + options: { + workflows: { workflows: { [workflow.id]: workflow }, customNodes: {} }, + }, + dependencies: {}, + } + await plugin(structure) + const moduleChunk = (structure.chunks as any[]).find((c: any) => c.name === 'workflow-module') + return moduleChunk ? String(moduleChunk.content) : null +} + +describe('event-page-loaded scoping', () => { + it('SKIPS a workflow with no page scoping at all (empty config)', async () => { + const code = await getWorkflowModule(pageLoadedWorkflow({}), 'page-1') + expect(code).toBeNull() + }) + + it('generates into every page when allPages is true', async () => { + const codeA = await getWorkflowModule(pageLoadedWorkflow({ allPages: true }), 'page-1') + const codeB = await getWorkflowModule(pageLoadedWorkflow({ allPages: true }), 'page-2') + expect(codeA).toContain('Page loaded') + expect(codeB).toContain('Page loaded') + }) + + it('generates only into the matching page when pageId is set', async () => { + const match = await getWorkflowModule(pageLoadedWorkflow({ pageId: 'page-1' }), 'page-1') + const mismatch = await getWorkflowModule(pageLoadedWorkflow({ pageId: 'page-1' }), 'page-2') + expect(match).toContain('Page loaded') + expect(mismatch).toBeNull() + }) + + it('generates into pages listed in selectedPages', async () => { + const config = { selectedPages: [{ id: 'page-1' }, { id: 'page-3' }] } + const inList = await getWorkflowModule(pageLoadedWorkflow(config), 'page-3') + const notInList = await getWorkflowModule(pageLoadedWorkflow(config), 'page-2') + expect(inList).toContain('Page loaded') + expect(notInList).toBeNull() + }) +}) diff --git a/packages/teleport-plugin-next-workflows/__tests__/route-changed-trigger.test.ts b/packages/teleport-plugin-next-workflows/__tests__/route-changed-trigger.test.ts new file mode 100644 index 000000000..24729ad1f --- /dev/null +++ b/packages/teleport-plugin-next-workflows/__tests__/route-changed-trigger.test.ts @@ -0,0 +1,106 @@ +// `event-route-changed` codegen: component-scoped triggers register a +// `Router.events` listener inside the owning component's lifecycle effect +// (with an `.off` cleanup so remounts don't leak listeners); unbound triggers +// are global-scoped and register in the `_app`-mounted global workflows hook, +// which must then import Router itself. + +import { createNextWorkflowPlugin } from '../src/workflow-component-plugin' +import { NextWorkflowProjectPlugin } from '../src/workflow-project-plugin' + +const routeChangedWorkflow = (scope: string, config: Record) => ({ + id: 'wf-route-changed', + name: 'Track Route', + trigger: { + type: 'event-route-changed', + nodeId: 'trigger-route-changed', + scope, + config, + }, + nodes: [ + { + id: 'update-1', + type: 'state-update-local-state', + config: { property: 'currentRoute', value: '' }, + stepNumber: 1, + label: 'Set route', + }, + ], + edges: [{ id: 'e1', source: 'trigger-route-changed', target: 'update-1' }], +}) + +const jsxComponentChunk = () => ({ + type: 'chunk-type-ast', + name: 'jsx-component', + content: { + type: 'VariableDeclaration', + declarations: [ + { + type: 'VariableDeclarator', + init: { + type: 'ArrowFunctionExpression', + body: { + type: 'BlockStatement', + body: [{ type: 'ReturnStatement', argument: null }], + }, + }, + }, + ], + }, +}) + +describe('event-route-changed — component scope', () => { + it('registers Router.events with an .off cleanup in the component lifecycle', async () => { + const workflow = routeChangedWorkflow('component', { componentName: 'Navigation' }) + const plugin = createNextWorkflowPlugin({ isPage: false }) + const structure: any = { + uidl: { + name: 'Navigation', + node: { type: 'element', content: { elementType: 'container', name: 'Container' } }, + stateDefinitions: { currentRoute: { type: 'string', defaultValue: '' } }, + }, + chunks: [jsxComponentChunk()], + options: { + workflows: { workflows: { [workflow.id]: workflow }, customNodes: {} }, + }, + dependencies: {}, + } + await plugin(structure) + const moduleChunk = (structure.chunks as any[]).find((c: any) => c.name === 'workflow-module') + expect(moduleChunk).toBeDefined() + const code = String(moduleChunk.content) + expect(code).toContain("Router.events.on('routeChangeComplete'") + expect(code).toContain("Router.events.off('routeChangeComplete'") + expect(code).toContain('cleanups.push') + expect(code).toContain('previousUrl') + }) +}) + +describe('event-route-changed — global scope', () => { + const plugin = new NextWorkflowProjectPlugin() + // generateGlobalWorkflowsHook is private; call it directly for a unit test. + const code = (plugin as any).generateGlobalWorkflowsHook([ + routeChangedWorkflow('global', {}), + ]) as string + + it('registers Router.events with an .off cleanup in the global hook', () => { + expect(code).toContain("Router.events.on('routeChangeComplete'") + expect(code).toContain("Router.events.off('routeChangeComplete'") + expect(code).toContain('previousUrl') + }) + + it('imports Router in the generated global-workflows file', () => { + expect(code).toContain("import Router from 'next/router';") + }) + + it('does NOT import Router when no route-changed workflow exists', () => { + const withoutRoute = (plugin as any).generateGlobalWorkflowsHook([ + { + id: 'wf-other', + trigger: { type: 'event-user-logged-in', config: {} }, + nodes: [], + edges: [], + }, + ]) as string + expect(withoutRoute).not.toContain("import Router from 'next/router';") + }) +}) diff --git a/packages/teleport-plugin-next-workflows/__tests__/state-update-missing-setter-warn.test.ts b/packages/teleport-plugin-next-workflows/__tests__/state-update-missing-setter-warn.test.ts new file mode 100644 index 000000000..235016064 --- /dev/null +++ b/packages/teleport-plugin-next-workflows/__tests__/state-update-missing-setter-warn.test.ts @@ -0,0 +1,180 @@ +// `__stateUpdateHandler` observability guard: writing to a state property the +// current page/component does NOT own must console.warn (and still resolve) +// instead of silently no-oping. The silent no-op masked the original +// "Auto-open Navigation Sidebar Group" bug for weeks: the workflow ran on +// every page, found no `sidebarGroupOpen` setter, and did nothing without a +// trace. Harness mirrors state-update-default-value.test.ts. + +import { createNextWorkflowPlugin } from '../src/workflow-component-plugin' + +const extractFunctionSource = (haystack: string, funcDecl: string): string => { + const startIdx = haystack.indexOf(funcDecl) + if (startIdx === -1) { + throw new Error('Helper not found: ' + funcDecl) + } + return braceMatchFrom(haystack, startIdx) +} + +const braceMatchFrom = (haystack: string, startIdx: number): string => { + let depth = 0 + let i = haystack.indexOf('{', startIdx) + if (i === -1) { + throw new Error('No opening brace from index ' + startIdx) + } + for (; i < haystack.length; i++) { + const ch = haystack.charAt(i) + if (ch === '{') { + depth++ + } else if (ch === '}') { + depth-- + if (depth === 0) { + return haystack.slice(startIdx, i + 1) + } + } + } + throw new Error('Unbalanced braces from index ' + startIdx) +} + +const buildStructure = (): any => ({ + uidl: { + name: 'Page', + outputOptions: { pageId: 'page-1', fileName: 'page-1' }, + node: { type: 'element', content: { elementType: 'container', name: 'Container' } }, + stateDefinitions: { known: { type: 'string', defaultValue: '' } }, + }, + chunks: [ + { + type: 'chunk-type-ast', + name: 'jsx-component', + content: { + type: 'VariableDeclaration', + declarations: [ + { + type: 'VariableDeclarator', + init: { + type: 'ArrowFunctionExpression', + body: { + type: 'BlockStatement', + body: [{ type: 'ReturnStatement', argument: null }], + }, + }, + }, + ], + }, + }, + ], + options: { + workflows: { + workflows: { + 'wf-1': { + id: 'wf-1', + name: 'Test WF', + trigger: { + type: 'event-page-loaded', + nodeId: 'trigger-1', + scope: 'page', + config: { pageId: 'page-1' }, + }, + nodes: [ + { + id: 'update-1', + type: 'state-update-local-state', + config: { property: 'known', value: 'x' }, + stepNumber: 1, + label: 'X', + }, + ], + edges: [{ id: 'e', source: 'trigger-1', target: 'update-1' }], + }, + }, + customNodes: {}, + }, + }, + dependencies: {}, +}) + +type HandlerFn = (config: any, context: any) => Promise + +const buildUpdateHandler = async ( + stateTypes: Record +): Promise<{ update: HandlerFn; writes: Record }> => { + const plugin = createNextWorkflowPlugin({ isPage: true }) + const structure = buildStructure() + await plugin(structure as any) + const moduleChunk = (structure.chunks as any[]).find((c: any) => c.name === 'workflow-module') + if (!moduleChunk) { + throw new Error('workflow-module chunk not emitted by plugin') + } + const moduleCode = String(moduleChunk.content) + + const coerceSrc = extractFunctionSource(moduleCode, 'function __coerceValue') + const defaultSrc = extractFunctionSource(moduleCode, 'function __defaultValueForType') + const updateSrc = extractFunctionSource(moduleCode, 'function __stateUpdateHandler') + + const writes: Record = {} + const stateSetters: Record void> = {} + for (const key of Object.keys(stateTypes)) { + stateSetters[key] = (v: unknown) => { + writes[key] = v + } + } + const stateValuesRef = { current: {} as Record } + + // eslint-disable-next-line @typescript-eslint/no-implied-eval + const factory = new Function( + 'stateSetters', + 'stateTypes', + 'stateValuesRef', + ` + var __stateNameMap = {}; + Object.keys(stateSetters).forEach(function(k) { __stateNameMap[k] = k; }); + function __resolveName(name) { return __stateNameMap[name] || name; } + ${coerceSrc} + ${defaultSrc} + ${updateSrc} + return __stateUpdateHandler; + ` + ) + const update = factory(stateSetters, stateTypes, stateValuesRef) as HandlerFn + return { update, writes } +} + +describe('state-update with a missing setter', () => { + let warnSpy: jest.SpyInstance + + beforeEach(() => { + warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined) + }) + + afterEach(() => { + warnSpy.mockRestore() + }) + + it('warns and still resolves for a plain-value update', async () => { + const { update, writes } = await buildUpdateHandler({ known: 'string' }) + const result = await update({ property: 'unknownProp', value: 'x' }, { __stateValues: {} }) + expect(result.success).toBe(true) + expect(writes.unknownProp).toBeUndefined() + expect(warnSpy).toHaveBeenCalledTimes(1) + expect(String(warnSpy.mock.calls[0][0])).toContain('unknownProp') + expect(String(warnSpy.mock.calls[0][0])).toContain('known') + }) + + it('warns and still resolves for an object property-mode update', async () => { + const { update } = await buildUpdateHandler({ known: 'string' }) + const result = await update( + { property: 'unknownObj', objectUpdateMode: 'property', objectPropertyPath: 'a', value: 1 }, + { __stateValues: {} } + ) + expect(result.success).toBe(true) + expect(warnSpy).toHaveBeenCalledTimes(1) + expect(String(warnSpy.mock.calls[0][0])).toContain('unknownObj') + }) + + it('does NOT warn when the setter exists', async () => { + const { update, writes } = await buildUpdateHandler({ known: 'string' }) + await update({ property: 'known', value: 'hello' }, { __stateValues: {} }) + expect(writes.known).toBe('hello') + expect(warnSpy).not.toHaveBeenCalled() + }) +}) diff --git a/packages/teleport-plugin-next-workflows/src/trigger-generator.ts b/packages/teleport-plugin-next-workflows/src/trigger-generator.ts index 515b9f199..a7ffed0f6 100644 --- a/packages/teleport-plugin-next-workflows/src/trigger-generator.ts +++ b/packages/teleport-plugin-next-workflows/src/trigger-generator.ts @@ -43,6 +43,10 @@ export const generateTriggerCode = (workflow: UIDLWorkflow, workflowVarName: str return generateFormSubmittedTrigger(config, executionCall, workflow.id) case 'event-page-loaded': return generatePageLoadedTrigger(config, executionCall, workflow.id) + case 'event-component-loaded': + return generateComponentLoadedTrigger(config, executionCall, workflow.id) + case 'event-route-changed': + return generateRouteChangedTrigger(executionCall, workflow.id) case 'event-user-logged-in': return generateCustomEventTrigger('workflow:user-logged-in', executionCall, workflow.id) case 'event-user-logged-out': @@ -299,6 +303,37 @@ const generatePageLoadedTrigger = ( }` } +const generateComponentLoadedTrigger = ( + config: Record, + executionCall: string, + workflowId: string +): string => { + const delay = config.delay as number | undefined + const componentId = (config.componentId as string) || '' + const componentName = (config.componentName as string) || '' + return ` + // Workflow trigger: component loaded (${workflowId}) + { + const triggerContext = { componentId: '${componentId}', componentName: '${componentName}', url: window.location.href, timestamp: Date.now() }; + ${delay ? `setTimeout(function() { ${executionCall}; }, ${delay});` : `${executionCall};`} + }` +} + +// NOTE: the consumer must have \`Router\` from 'next/router' in scope. +const generateRouteChangedTrigger = (executionCall: string, workflowId: string): string => { + const safeId = workflowId.replace(/[^a-zA-Z0-9]/g, '_') + return ` + // Workflow trigger: route changed (${workflowId}) + let __prevUrl_${safeId} = window.location.href; + const __rc_${safeId} = function(url) { + const triggerContext = { url: url, previousUrl: __prevUrl_${safeId}, pathname: window.location.pathname, timestamp: Date.now() }; + __prevUrl_${safeId} = window.location.href; + ${executionCall}; + }; + Router.events.on('routeChangeComplete', __rc_${safeId}); + return function() { Router.events.off('routeChangeComplete', __rc_${safeId}); };` +} + const generateCustomEventTrigger = ( eventName: string, executionCall: string, diff --git a/packages/teleport-plugin-next-workflows/src/workflow-component-plugin.ts b/packages/teleport-plugin-next-workflows/src/workflow-component-plugin.ts index ea1bacf1e..f3b3ac6aa 100644 --- a/packages/teleport-plugin-next-workflows/src/workflow-component-plugin.ts +++ b/packages/teleport-plugin-next-workflows/src/workflow-component-plugin.ts @@ -1559,7 +1559,8 @@ function __createWorkflowHandlers(stateSetters, stateTypes, stateValuesRef) { const newObj = Object.assign({}, currentObj); newObj[config.objectPropertyPath] = propValue; if (context && context.__stateValues) context.__stateValues[prop] = newObj; - if (stateSetters[prop]) stateSetters[prop](newObj); + if (stateSetters[prop]) { stateSetters[prop](newObj); } + else { console.warn('[workflow] state-update: no setter for "' + prop + '" in this page/component (available: ' + Object.keys(stateSetters).join(', ') + ') - update skipped. The workflow probably runs in a container that does not own this state.'); } return Promise.resolve({ success: true, property: prop, value: newObj }); } // A node wired without any value (the config has no \`value\` at all) falls @@ -1574,7 +1575,8 @@ function __createWorkflowHandlers(stateSetters, stateTypes, stateValuesRef) { return Promise.resolve({ success: true, property: prop, value: value }); } if (context && context.__stateValues) context.__stateValues[prop] = value; - if (stateSetters[prop]) stateSetters[prop](value); + if (stateSetters[prop]) { stateSetters[prop](value); } + else { console.warn('[workflow] state-update: no setter for "' + prop + '" in this page/component (available: ' + Object.keys(stateSetters).join(', ') + ') - update skipped. The workflow probably runs in a container that does not own this state.'); } return Promise.resolve({ success: true, property: prop, value: value }); } @@ -1938,6 +1940,35 @@ const generateLifecycleTrigger = (wf: UIDLWorkflow, safeId: string): string => { ) } + case 'event-component-loaded': { + const delay = config.delay as number | undefined + const componentId = (config.componentId as string) || '' + const componentName = (config.componentName as string) || '' + return ( + ` // Component loaded (${wf.name || wf.id})\n` + + ` {\n` + + ` const triggerContext = { componentId: '${componentId}', componentName: '${componentName}', url: window.location.href, timestamp: Date.now() };\n` + + (delay + ? ` setTimeout(function() { ${execCall}; }, ${delay});\n` + : ` ${execCall};\n`) + + ` }` + ) + } + + case 'event-route-changed': { + return ( + ` // Route changed (${wf.name || wf.id})\n` + + ` let __prevUrl_${safeId} = window.location.href;\n` + + ` const __rc_${safeId} = function(url) {\n` + + ` const triggerContext = { url: url, previousUrl: __prevUrl_${safeId}, pathname: window.location.pathname, timestamp: Date.now() };\n` + + ` __prevUrl_${safeId} = window.location.href;\n` + + ` ${execCall};\n` + + ` };\n` + + ` Router.events.on('routeChangeComplete', __rc_${safeId});\n` + + ` cleanups.push(function() { Router.events.off('routeChangeComplete', __rc_${safeId}); });` + ) + } + case 'event-element-visible': { const nodeId = config.nodeId as string const threshold = (config.threshold as number) || 0 @@ -2399,6 +2430,12 @@ const generateRealtimeUnsubscribeCode = ( } } +// Component-scope triggers match by emitted component name. The GUI stamps the +// name at export time, but generators may re-case or strip spaces during +// resolution, so equality is checked case/separator-insensitively. +const normalizeComponentName = (name: string): string => + (name || '').toLowerCase().replace(/[^a-z0-9]/g, '') + const getRelevantWorkflows = ( workflows: UIDLWorkflows, uidl: any, @@ -2480,10 +2517,42 @@ const getRelevantWorkflows = ( return } + if (trigger.scope === 'component') { + if (isPage) { + return + } + const cfg = trigger.config || {} + const cfgName = (cfg.componentName as string) || '' + const cfgId = (cfg.componentId as string) || '' + const matches = + (cfgName && + (cfgName === uidl.name || + normalizeComponentName(cfgName) === normalizeComponentName(uidl.name))) || + (cfgId && cfgId === pageId) + if (matches) { + relevant.push(wf) + } + return + } + if (trigger.scope === 'page' && isPage) { const triggerPageId = trigger.config.pageId as string const selectedPages = trigger.config.selectedPages as Array<{ id: string }> | undefined + if (trigger.type === 'event-page-loaded') { + const allPages = trigger.config.allPages === true + if (allPages) { + relevant.push(wf) + return + } + // Unscoped page-loaded workflows are SKIPPED, not injected everywhere. + // Silently fanning out to every page masks mis-scoped workflows whose + // state target lives in a component (the setter no-ops on pages). + if (!triggerPageId && (!selectedPages || selectedPages.length === 0)) { + return + } + } + if (triggerPageId && triggerPageId !== pageId) { return } diff --git a/packages/teleport-plugin-next-workflows/src/workflow-project-plugin.ts b/packages/teleport-plugin-next-workflows/src/workflow-project-plugin.ts index 2370ad99a..20d46dcee 100644 --- a/packages/teleport-plugin-next-workflows/src/workflow-project-plugin.ts +++ b/packages/teleport-plugin-next-workflows/src/workflow-project-plugin.ts @@ -568,6 +568,36 @@ export class NextWorkflowProjectPlugin implements ProjectPlugin { // payment-charge-user.ts) and caches the result. } + // Generation-time diagnostics for workflows that will be silently dropped + // by the per-page/per-component routing (getRelevantWorkflows). + ;(Object.values(allWorkflows) as any[]).forEach((wf: any) => { + const trigger = wf?.trigger + if (!trigger) { + return + } + if (trigger.type === 'event-page-loaded' && trigger.scope === 'page') { + const cfg = trigger.config || {} + const hasSelectedPages = Array.isArray(cfg.selectedPages) && cfg.selectedPages.length > 0 + if (cfg.allPages !== true && !cfg.pageId && !hasSelectedPages) { + console.warn( + `[teleport-plugin-next-workflows] Workflow "${ + wf.name || wf.id + }" (event-page-loaded) has no page scoping (pageId / selectedPages / allPages) - skipping code generation for it. Re-save the trigger and pick a page or "All pages".` + ) + } + } + if (trigger.scope === 'component') { + const cfg = trigger.config || {} + if (!cfg.componentId && !cfg.componentName) { + console.warn( + `[teleport-plugin-next-workflows] Workflow "${wf.name || wf.id}" (${ + trigger.type + }) is component-scoped but has no componentId/componentName - it will not match any component and is skipped. Re-save the trigger binding in the editor.` + ) + } + } + }) + const globalWorkflows = (Object.values(allWorkflows) as any[]).filter( (wf: any) => wf.trigger.scope === 'global' && @@ -1516,9 +1546,21 @@ module.exports = __customNodeRegistry; }; window.addEventListener('workflow:user-logged-out', handler_${safeId}); cleanups.push(function() { window.removeEventListener('workflow:user-logged-out', handler_${safeId}); });`) + } else if (trigger.type === 'event-route-changed') { + registrations.push(` + const prevUrl_${safeId} = { current: typeof window !== 'undefined' ? window.location.href : '' }; + const handler_${safeId} = function(url) { + const ctx = { url: url, previousUrl: prevUrl_${safeId}.current, pathname: window.location.pathname, timestamp: Date.now() }; + prevUrl_${safeId}.current = window.location.href; + executeWorkflow_${safeId}(ctx).catch(function() {}); + }; + Router.events.on('routeChangeComplete', handler_${safeId}); + cleanups.push(function() { Router.events.off('routeChangeComplete', handler_${safeId}); });`) } } + const needsRouter = workflows.some((wf) => wf.trigger?.type === 'event-route-changed') + // Emit a handler function AND a `clientNodeHandlers` map entry ONLY for node // types that actually have a registry generator. These two lists MUST stay // paired: previously `handlers` was gated on `if (gen)` while `handlerEntries` @@ -1585,7 +1627,7 @@ module.exports = __customNodeRegistry; // pure-ESM; SWC interop resolves the default to `module.exports`. return `// Auto-generated global workflow hooks import { useEffect } from 'react'; -import workflowRuntime from './runtime'; +${needsRouter ? `import Router from 'next/router';\n` : ''}import workflowRuntime from './runtime'; const executeWorkflowWithSegments = workflowRuntime.executeWorkflowWithSegments; export function useGlobalWorkflows() { diff --git a/packages/teleport-shared/src/utils/generic.ts b/packages/teleport-shared/src/utils/generic.ts index a56fb34da..aec98442a 100644 --- a/packages/teleport-shared/src/utils/generic.ts +++ b/packages/teleport-shared/src/utils/generic.ts @@ -30,8 +30,12 @@ import { UIDLPropDefinition, UIDLStateDefinition } from '@teleporthq/teleport-ty * inside teleport-gui's packer Web Worker MUST go through this helper * instead of calling `relative()` directly. */ +// The backslash normalization matters when generating on Windows under real +// Node: win32 `relative()` returns '..\\..\\resources\\x', and module +// specifiers must always use forward slashes or webpack treats them as bare +// package names ("Module not found: Can't resolve '..\\..\\resources\\x'"). export const localRelativePath = (from: string, to: string): string => - relative(`/${from}`, `/${to}`) + relative(`/${from}`, `/${to}`).replace(/\\/g, '/') export const generateLocalDependenciesPrefix = (fromPath: string[], toPath: string[]): string => { /* diff --git a/packages/teleport-types/src/uidl.ts b/packages/teleport-types/src/uidl.ts index 5a96bd7a3..efa95fbb6 100755 --- a/packages/teleport-types/src/uidl.ts +++ b/packages/teleport-types/src/uidl.ts @@ -369,7 +369,7 @@ export interface UIDLWorkflowTrigger { nodeId: string type: string config: Record - scope: 'global' | 'page' | 'element' + scope: 'global' | 'page' | 'element' | 'component' } export interface UIDLWorkflowErrorHandler { diff --git a/scripts/watcher.mjs b/scripts/watcher.mjs index 415f80b47..0a1d3488f 100644 --- a/scripts/watcher.mjs +++ b/scripts/watcher.mjs @@ -15,7 +15,7 @@ const watcher = chokidar.watch(['packages/**/src/**/*.ts', 'packages/**/src/**/* log(chalk.yellow.bold('Watching all files... 👀')) watcher.on('change', async (filePath) => { - const splitPath = filePath.split('/') + const splitPath = filePath.split(/[\\/]/) const location = `${splitPath[0]}/${splitPath[1]}/` const fileName = splitPath[1]