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:
| Case | Context | Result |
|---|---|---|
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.
| Package | At-risk template calls |
|---|---|
packages/shared-ui | 34 |
packages/widget | 13 |
packages/pages-restaurants | 5 |
packages/pages-package | 4 |
packages/pages-profile | 2 |
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({.)
Recommended fix (Phase C)
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;RegisterFormRefundGuaranteeTncfixed; 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 viafillInsertion(loop/slot-scoped ones via a computed-returning-a-function);vue/no-restricted-syntaxguard 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-
.valuetemplate 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.valuetemplate form) is still worth filing. - Phase E (done): adopted the no-
.valueform repo-wide. AllfillInsertioncall sites (42.vuefiles) converted to inline{{ il.key({...}) }}/v-html="il.key({...})"(the few script-side cases useil.key.value({...})). ThefillInsertionhelper + its test + the./intlayerexport were removed (no longer needed). Thevue/no-restricted-syntaxguard is kept (reworded) — it still blocks the crashing.value({...})template form. Also proved thev-htmlno-.valuepath (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.
| Case | Form (in <template> unless noted) | common.subscribeNewsLetter | package.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 correctly | n/a |
| B (control) | <script> il.key.value({...}) computed | ✅ filled | n/a |
| E (control) | <script> fillInsertion(il.key, …) computed | ✅ filled | n/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.