Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Intlayer insertion-key optimize-build crash — gap analysis (Phases A–E)

The bug

Calling a vue-intlayer insertion key as useIntlayer("dict").<key>.value({ ...params }) throws TypeError: <key>.value is not a function in the optimized production build (CF deploy preview / vite preview of apps/ionic/dist), blanking the whole component. Dev does NOT reproduce it (Intlayer optimize is production-only). Unit tests can’t catch it (the vitest stub at packages/config-pages/src/intlayerTestStub.ts always makes .value callable).

Root cause — confirmed differentiator (CONTEXT, not key)

A controlled probe (4 cases, isolated by error boundaries, in a real optimized build) proved:

CaseContextResult
common.subscribeNewsLetter<script> computed✅ OK (rendered, placeholder filled)
common.subscribeNewsLetter<template> text❌ THREW .value is not a function
common.subscribeNewsLetter<template> v-html❌ THREW
package.estPrice<template> text❌ THREW

The @intlayer/babel optimize transform rewrites insertion .value() calls only in <script> (where useIntlayer("…") is a local binding it can statically resolve). In a <template> (compiled to a render fn that accesses the dictionary via _ctx), the call is not rewritten → .value is the raw insertion string → calling it throws. It is purely context-based — the same key works in script and throws in template, and estPrice (which works in prod because its real call site is in script) also throws in template.

Rule: at-risk = any insertion .value({...}) in a <template> block (text / v-html / attribute). Calls in <script> (computeds, functions) are safe.

Method

Static split of every .vue in packages/ + apps/ into <template> vs <script>, counting .value({ insertion calls in each (the proven rule makes this deterministic; the probe is the empirical validation). Caveat: calls that pass a variable instead of an inline {…} object are not matched by the counter — rare, but re-scan if a key takes a pre-built params object.

Inventory (post Phase-A fixes)

58 at-risk template insertion calls across 33 files (84 script calls are safe). Already fixed in Phase A (PR #1052) and therefore absent below: RegisterForm.vue, RefundGuaranteeTnc.vue.

PackageAt-risk template calls
packages/shared-ui34
packages/widget13
packages/pages-restaurants5
packages/pages-package4
packages/pages-profile2

Per file (count — file — keys):

5  shared-ui/src/components/member/ProfileDesktop.vue            [hungryPoints, xPointsToBeEarned, giveTheGiftAndGetOne, giveYourFriend]
4  shared-ui/src/components/package/ComeMorePayLessLabel.vue     [getPay, comePay, forPack, forPerson]
4  shared-ui/src/components/package/ComeMorePayLessSinglePrice.vue [getPay, comePay, forPack, promotionPeriod]
3  shared-ui/src/components/booking/PointRedemption.vue          [xPoints, estPrice]
3  shared-ui/src/components/profile/ProfileMobile.vue            [hungryPoints, xPointsToBeEarned]
3  shared-ui/src/components/referral/HowDoesItWork.vue           [shareWithFriend, giftYourFriend, earnHungryPoint]
3  widget/src/booking/BookingModalsDesktop.vue                   [bigGroupDining, thisPackageRequiresAMinimum, thisPackageRequiresAMaximum]
2  pages-package/src/ui/qr_menu/MenuSectionName.vue              [chooseItemSingular, chooseItem]
2  pages-restaurants/src/detail/ui/RestaurantBottomSheetMobile.vue [detailEarnHungryhubPoints, hungryPoint1Thb]
2  pages-restaurants/src/detail/ui/RestaurantInformationMobile.vue [detailEarnHungryhubPoints, hungryPoint1Thb]
2  shared-ui/src/components/member/benefit/MyBenefit.vue         [moreBooking, earnAtLeast1Point]
2  shared-ui/src/components/member/benefit/MyBenefitMobile.vue   [moreBooking, earnAtLeast1Point]
2  widget/src/booking/ui/BookingSelectDateTimePage.vue          [minuteInAdvance, minuteInAdvancePast]
2  widget/src/booking/ui/step/SelectDateTimeStepDesktop.vue     [minuteInAdvance, minuteInAdvancePast]
1  pages-package/src/partner_booking/ui/PackageNotFoundOverlay.vue [redirectingInSeconds]
1  pages-package/src/ui/package_comparison/PackageComparison.vue [totalDishCount]
1  pages-profile/src/profile/offers-and-gift-card/AvailablePromoCode.vue [instantDiscount]
1  pages-profile/src/profile/point/components/ToBeEarnedPoint.vue [pointToBeEarned]
1  pages-restaurants/src/detail/ui/header/RestaurantNewLabelMobile.vue [firstReview]
1  shared-ui/src/components/bitelist/BiteListCardDesktop.vue     [countSaved]
1  shared-ui/src/components/favourites/FavouriteCardDesktop.vue  [fromPrice]
1  shared-ui/src/components/favourites/FavouriteCardMobile.vue   [fromPrice]
1  shared-ui/src/components/package/ComeMorePayLessMultiPrice.vue [promotionPeriod]
1  shared-ui/src/components/payment/QrPaymentPointX.vue          [scanQrWithLogo]
1  shared-ui/src/components/promo/PromoCodeCard.vue              [estPrice]
1  shared-ui/src/components/referral/ReferFriend.vue            [sendThisCodeToYourFriend]
1  shared-ui/src/components/referral/ReferralModal.vue          [earnPointsMessage]
1  widget/src/booking/ui/BigGroupInfoPopup.vue                  [bigGroupDining]
1  widget/src/booking/ui/BigGroupShortcut.vue                   [moreThan]
1  widget/src/booking/ui/PartySizeTooSmallOverlay.vue           [doesntReachMinimumSeat]
1  widget/src/booking/ui/checkout/BookingPayment.vue            [youWontBeCharged]
1  widget/src/booking/ui/checkout/OffersAndGiftCard.vue         [xPointsRedeemed]
1  widget/src/on_hold_charge/components/OnHoldDescription.vue    [onHoldCancelMinutes]

(Reproduce: node over the repo splitting SFCs into template/script and counting .value({.)

For each at-risk call, move the insertion call into a <script> computed via fillInsertion(il.key, { … }) from @hh-pegasus/shared-helper/intlayer, then reference the computed in the template. (A plain script computed alone also works — the transform rewrites script calls — but fillInsertion is robust even if that behavior shifts.)

Then add a precise ESLint guard (now feasible since the at-risk set is template-only): forbid .value({ … }) call expressions inside Vue <template> (via vue-eslint-parser template AST), steering all such calls into script. This replaces the heuristic il*-naming rule and won’t false-flag the 84 safe script calls.

Correction: the static count under-reported

The 58 figure above came from a regex scan that (a) only matched inline .value({ object args and (b) used a non-greedy <template> match that stopped at the first nested </template> slot. The ESLint guard is the source of truth — it found 17 more at-risk calls (variable/locale args and calls inside nested <template> regions), notably widget/.../CheckOutPage.vue (10), AvailableDateSection, PreSelectSpecialMenu, FavouriteMobile, PointWillBeExpired, BitelistSettings, and apps/ionic/.../confirmation/[client].vue. Real total fixed: 75.

Status

  • Phase A (done, PR #1052): helper promoted to @hh-pegasus/shared-helper/intlayer; RegisterForm
    • RefundGuaranteeTnc fixed; verified on the CF deploy preview.
  • Phase B (done): root cause proven by probe; inventory (static 58, later corrected to 75 by the guard).
  • Phase C (done): all 75 template insertion calls converted to <script> computeds via fillInsertion (loop/slot-scoped ones via a computed-returning-a-function); vue/no-restricted-syntax guard added to @repo/eslint-config/vue (0 violations repo-wide). Verified: typecheck, lint, tests, build, and CF deploy preview all green; 0 template .value( calls remain.
  • Phase D (done — see below): proved the no-.value template form {{ il.key({...}) }} renders correctly in the optimized build. The repo is already on the latest @intlayer (8.12.4) — no version-bump fix available; a minimal upstream issue (optimize should also rewrite the .value template form) is still worth filing.
  • Phase E (done): adopted the no-.value form repo-wide. All fillInsertion call sites (42 .vue files) converted to inline {{ il.key({...}) }} / v-html="il.key({...})" (the few script-side cases use il.key.value({...})). The fillInsertion helper + its test + the ./intlayer export were removed (no longer needed). The vue/no-restricted-syntax guard is kept (reworded) — it still blocks the crashing .value({...}) template form. Also proved the v-html no-.value path (HTML insertion renders filled anchors + hrefs). Verified: typecheck, lint, tests, build, CF deploy preview.

Phase D — proof that {{ il.key({...}) }} (no .value) is the real fix

Hypothesis: the crash is caused specifically by the redundant .value in templates, not by calling an insertion key in a template per se. Vue auto-unwraps the ref il.key in templates (the repo already relies on this for plain keys: {{ il.key }}), so the auto-unwrapped node should be callable — and {{ il.key({...}) }} should render the filled string even after optimize.

Method (controlled probe, real optimized build). A throwaway route + per-case error-boundary components built with pnpm build (NODE_ENV=production → optimize: true) and served via vite preview (the same optimized bundle CF serves). Two insertion keys from two dictionaries were tested across forms. Case A/A2 are sentinels — the known-crashing .value({...}) template form; their failure proves the optimize transform actually reached the probe (otherwise the whole test is inconclusive). Observed via chrome-devtools: per-case rendered text + error, console.

CaseForm (in <template> unless noted)common.subscribeNewsLetterpackage.estPrice
A / A2 (sentinel)il.key.value({...})❌ …value is not a function❌ …value is not a function
C / C2{{ il.key({...}) }} (no .value)✅ filled correctly✅ filled correctly
D{{ key({...}) }} (destructured, no .value)✅ filled correctlyn/a
B (control)<script> il.key.value({...}) computed✅ filledn/a
E (control)<script> fillInsertion(il.key, …) computed✅ filledn/a

Filled output verified character-for-character (e.g. "I want to receive ฿150 promo code, …", "Est. ฿1,234") — not empty, not a raw {token}. Both sentinels threw; no other console errors.

Verdict. The .value is the entire culprit. {{ il.key({...}) }} (and the destructured {{ key({...}) }}) renders the filled string correctly in the optimized production build, across dictionaries. This is the Intlayer-idiomatic form (templates render the node directly; .value is a <script>-only unwrap) and needs no helper — so fillInsertion was removed in Phase E.

Adoption (Phase E, done): all fillInsertion call sites were converted to the inline no-.value form and the helper was removed. The ESLint guard still applies — it forbids the .value({...}) call, which the no-.value form avoids, so 0 violations remain.

Phase F — bound-attribute variant + dev/prod claim now stale (PR #1225)

A second, related bug class surfaced in PR #1225: binding a bare Intlayer key (or .value, or a direct call missing .raw) to a Vue bound attribute (:prop="...") passes the callable node object instead of a string, failing Vue’s own prop-type check — a different failure mode than the .value({...})-in-template crash documented above, though the same root confusion (forgetting the node isn’t a plain string). Confirmed the same “no .value in a template” rule extends to bound attributes too: .value(...) throws there just like in {{ }}/v-html. Fix, verified live: append .raw to whichever form is already correct for the key type — :prop="il.key.raw" (plain key) or :prop="il.key({...params}).raw" (insertion key, direct call). New ESLint rule local-intlayer/prop-bind (@repo/eslint-config/vue.js) catches this, complementing the vue/no-restricted-syntax guard above.

Correction to this doc’s dev/prod claim: re-testing on the current vue-intlayer@8.12.4 found that the exact sentinel form from Phase D’s probe table ({{ il.key.value({...}) }}, bare, no .raw) now also throws in pnpm dev (Vite dev server, not just the optimized build) — "<key>.value is not a function". This contradicts “Dev does NOT reproduce it (Intlayer optimize is production-only)” above; that claim held at the time of Phases A–E but the underlying vue-intlayer/ @intlayer versions have moved since. Practical upshot is the same either way — never write .value(...) inside a <template>, in any context — but don’t rely on dev looking clean as evidence a .value({...}) template call is actually safe going forward; re-verify against the installed version if this matters again.