diff --git a/packages/compiler-cli/src/ngtsc/typecheck/extended/checks/defer_trigger_misconfiguration/index.ts b/packages/compiler-cli/src/ngtsc/typecheck/extended/checks/defer_trigger_misconfiguration/index.ts index e947f3af2bbb..9632a5c2496c 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/extended/checks/defer_trigger_misconfiguration/index.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/extended/checks/defer_trigger_misconfiguration/index.ts @@ -12,6 +12,7 @@ import { TmplAstDeferredBlock, TmplAstDeferredTrigger, TmplAstHoverDeferredTrigger, + TmplAstIdleDeferredTrigger, TmplAstImmediateDeferredTrigger, TmplAstInteractionDeferredTrigger, TmplAstTimerDeferredTrigger, @@ -60,6 +61,26 @@ function areLiteralMapsEqual(a: LiteralMap | null, b: LiteralMap | null): boolea return true; } +function getTimedTriggerValue( + trigger: TmplAstTimerDeferredTrigger | TmplAstIdleDeferredTrigger, +): number | null { + if (trigger instanceof TmplAstTimerDeferredTrigger) { + return trigger.delay; + } + + return trigger.timeout; +} + +function formatTimedTrigger( + trigger: TmplAstTimerDeferredTrigger | TmplAstIdleDeferredTrigger, +): string { + if (trigger instanceof TmplAstTimerDeferredTrigger) { + return `timer(${trigger.delay}ms)`; + } + + return trigger.timeout === null ? 'idle' : `idle(${trigger.timeout}ms)`; +} + /** * This check implements warnings for unreachable or redundant @defer triggers. * Emits ErrorCode.DEFER_TRIGGER_MISCONFIGURATION with messages matching the project's @@ -114,26 +135,54 @@ class DeferTriggerMisconfiguration extends TemplateCheckWithVisitor 0) { + const msg = + `Define a main trigger when using 'prefetch' triggers. ` + + `Without an explicit main trigger, @defer defaults to 'idle' and prefetch may have no effect.`; + diags.push( + ctx.makeTemplateDiagnostic( + node.sourceSpan, + formatExtendedError(ErrorCode.DEFER_TRIGGER_MISCONFIGURATION, msg), + ), + ); + } + // If there is exactly one main and at least one prefetch, compare them. if (mains.length === 1 && prefetches.length > 0) { const main = mains[0]; for (const pre of prefetches) { - // Timer vs Timer: warn when prefetch delay >= main delay - const isTimerTriggger = + // Delay-based pairs (timer/idle): warn when prefetch fires no sooner than main. + + const isTimerPair = main instanceof TmplAstTimerDeferredTrigger && pre instanceof TmplAstTimerDeferredTrigger; - if (isTimerTriggger) { - const mainDelay = main.delay; - const preDelay = pre.delay; - if (preDelay >= mainDelay) { - const msg = `The Prefetch 'timer(${preDelay}ms)' is not scheduled before the main 'timer(${mainDelay}ms)', so it won’t run prior to rendering. Lower the prefetch delay or remove it.`; - diags.push( - ctx.makeTemplateDiagnostic( - pre.sourceSpan ?? node.sourceSpan, - formatExtendedError(ErrorCode.DEFER_TRIGGER_MISCONFIGURATION, msg), - ), - ); + + const isIdlePair = + main instanceof TmplAstIdleDeferredTrigger && pre instanceof TmplAstIdleDeferredTrigger; + + if (isTimerPair || isIdlePair) { + const mainVal = getTimedTriggerValue(main); + const preVal = getTimedTriggerValue(pre); + const sameUntimedIdle = mainVal == null && preVal == null; + const comparableTimedPair = mainVal != null && preVal != null && preVal >= mainVal; + + if (!sameUntimedIdle && !comparableTimedPair) { + continue; } + + const msg = + `The Prefetch '${formatTimedTrigger(pre)}' is not scheduled before the main '${formatTimedTrigger(main)}',` + + ` so it won't run prior to rendering. Lower the prefetch timing or remove it.`; + + diags.push( + ctx.makeTemplateDiagnostic( + pre.sourceSpan ?? node.sourceSpan, + formatExtendedError(ErrorCode.DEFER_TRIGGER_MISCONFIGURATION, msg), + ), + ); + + continue; } // Reference-based triggers (hover/interaction/viewport): warn if both diff --git a/packages/compiler-cli/src/ngtsc/typecheck/extended/test/checks/defer_trigger_misconfiguration/defer_trigger_misconfiguration_spec.ts b/packages/compiler-cli/src/ngtsc/typecheck/extended/test/checks/defer_trigger_misconfiguration/defer_trigger_misconfiguration_spec.ts index 6b7c1ed97e91..df1ff63f5781 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/extended/test/checks/defer_trigger_misconfiguration/defer_trigger_misconfiguration_spec.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/extended/test/checks/defer_trigger_misconfiguration/defer_trigger_misconfiguration_spec.ts @@ -67,6 +67,35 @@ runInEachFileSystem(() => { expect(diags[0].code).toBe(ngErrorCode(ErrorCode.DEFER_TRIGGER_MISCONFIGURATION)); }); + it('should emit when prefetch idle timeout >= main idle timeout', () => { + const diags = getDiags(`@defer (on idle(1s); prefetch on idle(2s)) {
}`); + expect(diags.length).toBe(1); + expect(diags[0].category).toBe(ts.DiagnosticCategory.Warning); + expect(diags[0].code).toBe(ngErrorCode(ErrorCode.DEFER_TRIGGER_MISCONFIGURATION)); + }); + + it('should not emit when prefetch idle timeout < main idle timeout', () => { + const diags = getDiags(`@defer (on idle(2s); prefetch on idle(1s)) {
}`); + expect(diags.length).toBe(0); + }); + + it('should emit when prefetch idle matches main idle with no timeout', () => { + const diags = getDiags(`@defer (on idle; prefetch on idle) {
}`); + expect(diags.length).toBe(1); + expect(diags[0].category).toBe(ts.DiagnosticCategory.Warning); + expect(diags[0].code).toBe(ngErrorCode(ErrorCode.DEFER_TRIGGER_MISCONFIGURATION)); + }); + + it('should not emit when only main idle has timeout', () => { + const diags = getDiags(`@defer (on idle(1s); prefetch on idle) {
}`); + expect(diags.length).toBe(0); + }); + + it('should not emit when only prefetch idle has timeout', () => { + const diags = getDiags(`@defer (on idle; prefetch on idle(1s)) {
}`); + expect(diags.length).toBe(0); + }); + it('should emit when prefetch identical to main viewport/interaction/hover', () => { const diags = getDiags( `@defer (on viewport(ref); prefetch on viewport(ref)) {
}`, @@ -151,5 +180,33 @@ runInEachFileSystem(() => { expect(diags[0].category).toBe(ts.DiagnosticCategory.Warning); expect(diags[0].code).toBe(ngErrorCode(ErrorCode.DEFER_TRIGGER_MISCONFIGURATION)); }); + + it('should emit when prefetch trigger is configured without a main trigger', () => { + const diags = getDiags(`@defer (prefetch on viewport(ref)) {
}`); + expect(diags.length).toBe(1); + expect(diags[0].category).toBe(ts.DiagnosticCategory.Warning); + expect(diags[0].code).toBe(ngErrorCode(ErrorCode.DEFER_TRIGGER_MISCONFIGURATION)); + }); + + it('should emit when prefetch timer is configured without a main trigger', () => { + const diags = getDiags(`@defer (prefetch on timer(500ms)) {
}`); + expect(diags.length).toBe(1); + expect(diags[0].category).toBe(ts.DiagnosticCategory.Warning); + expect(diags[0].code).toBe(ngErrorCode(ErrorCode.DEFER_TRIGGER_MISCONFIGURATION)); + }); + + it('should emit when prefetch idle is configured without a main trigger', () => { + const diags = getDiags(`@defer (prefetch on idle(500)) {
}`); + expect(diags.length).toBe(1); + expect(diags[0].category).toBe(ts.DiagnosticCategory.Warning); + expect(diags[0].code).toBe(ngErrorCode(ErrorCode.DEFER_TRIGGER_MISCONFIGURATION)); + }); + + it('should emit when prefetch when is configured without a main trigger', () => { + const diags = getDiags(`@defer (prefetch when true) {
}`); + expect(diags.length).toBe(1); + expect(diags[0].category).toBe(ts.DiagnosticCategory.Warning); + expect(diags[0].code).toBe(ngErrorCode(ErrorCode.DEFER_TRIGGER_MISCONFIGURATION)); + }); }); });